Q189: search says how many match, pages through all of them, and says when nothing matches
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b7106d511
commit
bc7e214b65
12 files changed
+163
-27
No files matched your search
@@ -80,6 +80,6 @@ export const platformContracts = {
|
||||
z.strictObject({ orgId: id, spaceId: id, beforeMessageId: id.nullable(), fromMessageId: id }),
|
||||
]),
|
||||
search: z.strictObject({
|
||||
orgId: id, employeeId: id, query: z.string(), entityTypes: z.array(z.enum(SEARCH_ENTITY_TYPES)).optional(), limit: z.number().optional(),
|
||||
orgId: id, employeeId: id, query: z.string(), entityTypes: z.array(z.enum(SEARCH_ENTITY_TYPES)).optional(), limit: z.number().optional(), offset: z.number().int().min(0).optional(),
|
||||
}) satisfies z.ZodType<C.SearchInput>,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { SearchInput } from "../../shared/commands";
|
||||
import { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "../../shared/domain";
|
||||
import { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchPage } from "../../shared/domain";
|
||||
|
||||
interface SearchRow {
|
||||
entity_type: SearchEntityType;
|
||||
@@ -21,10 +21,12 @@ interface SearchDoc {
|
||||
export class SearchService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
search(input: SearchInput): SearchResultRecord[] {
|
||||
/** Every match the person may read, best first, counted in full before one page is cut from it. */
|
||||
search(input: SearchInput): SearchPage {
|
||||
this.assertEmployee(input.orgId, input.employeeId);
|
||||
const query = normalizeQuery(input.query);
|
||||
const limit = Math.max(1, Math.min(input.limit ?? 20, 50));
|
||||
const offset = Math.max(0, input.offset ?? 0);
|
||||
const entityTypes = validEntityTypes(input.entityTypes ?? [...SEARCH_ENTITY_TYPES]);
|
||||
this.reindexOrg(input.orgId);
|
||||
const rows = query
|
||||
@@ -32,19 +34,19 @@ export class SearchService {
|
||||
select entity_type, entity_id, title, body, facet_json
|
||||
from search_documents
|
||||
where search_documents match ? and org_id = ?
|
||||
limit ?
|
||||
`).all(query, input.orgId, limit * 4) as unknown as SearchRow[]
|
||||
order by rank
|
||||
`).all(query, input.orgId) as unknown as SearchRow[]
|
||||
: this.db.prepare(`
|
||||
select entity_type, entity_id, title, body, facet_json
|
||||
from search_documents
|
||||
where org_id = ?
|
||||
order by rowid desc
|
||||
limit ?
|
||||
`).all(input.orgId, limit * 4) as unknown as SearchRow[];
|
||||
return rows
|
||||
`).all(input.orgId) as unknown as SearchRow[];
|
||||
const readable = rows
|
||||
.filter((row) => entityTypes.has(row.entity_type))
|
||||
.filter((row) => this.canRead(input.orgId, input.employeeId, row.entity_type, row.entity_id))
|
||||
.slice(0, limit)
|
||||
.filter((row) => this.canRead(input.orgId, input.employeeId, row.entity_type, row.entity_id));
|
||||
const results = readable
|
||||
.slice(offset, offset + limit)
|
||||
.map((row) => ({
|
||||
entityType: row.entity_type,
|
||||
entityId: row.entity_id,
|
||||
@@ -52,6 +54,7 @@ export class SearchService {
|
||||
preview: preview(row.body),
|
||||
facets: parseFacets(row.facet_json),
|
||||
}));
|
||||
return { results, total: readable.length };
|
||||
}
|
||||
|
||||
private reindexOrg(orgId: string): void {
|
||||
|
||||
@@ -697,6 +697,9 @@ export const zh: Record<string, string> = {
|
||||
"That did not work": "操作未成功",
|
||||
"Working…": "处理中…",
|
||||
"Searching…": "正在搜索…",
|
||||
"Nothing matches “{words}”.": "没有与“{words}”匹配的结果。",
|
||||
"Show more": "显示更多",
|
||||
"Loading…": "正在加载…",
|
||||
"Search failed: {reason}": "搜索失败:{reason}",
|
||||
"Not up to date.": "数据不是最新的。",
|
||||
"Showing data from {time}.": "当前显示 {time} 的数据。",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withNextPage, type SearchAnswer } from "./search-pages";
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react";
|
||||
import type { AppSnapshot, SearchEntityType, SearchResultRecord } from "../../../shared/domain";
|
||||
import { SEARCH_ENTITY_TYPES } from "../../../shared/domain";
|
||||
@@ -6,6 +7,8 @@ import type { Focus } from "../pages/types";
|
||||
import { Button, Icon, Pill } from "./ui";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
const PAGE = 12;
|
||||
|
||||
type SearchFilter = "all" | SearchEntityType;
|
||||
|
||||
const LABELS: Record<SearchEntityType, string> = {
|
||||
@@ -38,40 +41,51 @@ export function SearchDialog({
|
||||
const [query, setQuery] = useState("");
|
||||
const [filter, setFilter] = useState<SearchFilter>("all");
|
||||
// An answer belongs to the company, filter and words it was asked for; only the current question's answer is shown.
|
||||
const [answer, setAnswer] = useState<{ key: string; results: SearchResultRecord[]; error: string | null } | null>(null);
|
||||
const [answer, setAnswer] = useState<SearchAnswer | null>(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const orgId = snapshot.activeOrgId;
|
||||
const employee = useMemo(() => snapshot.employees.find((item) => item.orgId === orgId), [snapshot.employees, orgId]);
|
||||
// The id, not the record: a snapshot refresh must not ask again and reset the pages already shown.
|
||||
const employeeId = employee?.id ?? null;
|
||||
const trimmed = query.trim();
|
||||
const canSearch = Boolean(orgId && employee && trimmed.length >= 2);
|
||||
const key = `${orgId}|${filter}|${trimmed}`;
|
||||
const current = canSearch && answer?.key === key ? answer : null;
|
||||
const visibleResults = current?.results ?? [];
|
||||
const searching = canSearch && !current;
|
||||
const ask = (words: string, offset: number) => window.agentCompany.search({
|
||||
orgId: orgId ?? "",
|
||||
employeeId: employeeId ?? "",
|
||||
query: words,
|
||||
entityTypes: filter === "all" ? undefined : [filter],
|
||||
limit: PAGE,
|
||||
offset,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !orgId || !employee || trimmed.length < 2) return;
|
||||
if (!open || !orgId || !employeeId || trimmed.length < 2) return;
|
||||
// A reply to a question that has since changed is dropped, so a slow old answer never replaces a newer one.
|
||||
let live = true;
|
||||
const asked = `${orgId}|${filter}|${trimmed}`;
|
||||
const timeout = window.setTimeout(() => {
|
||||
void window.agentCompany.search({
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
employeeId,
|
||||
query: trimmed,
|
||||
entityTypes: filter === "all" ? undefined : [filter],
|
||||
limit: 12,
|
||||
limit: PAGE,
|
||||
}).then((result) => {
|
||||
if (!live) return;
|
||||
setAnswer(result.ok ? { key: asked, results: result.data, error: null } : { key: asked, results: [], error: result.error.message });
|
||||
setAnswer(result.ok ? { key: asked, ...result.data, error: null } : { key: asked, results: [], total: 0, error: result.error.message });
|
||||
}, (reason: unknown) => {
|
||||
if (live) setAnswer({ key: asked, results: [], error: reason instanceof Error ? reason.message : String(reason) });
|
||||
if (live) setAnswer({ key: asked, results: [], total: 0, error: reason instanceof Error ? reason.message : String(reason) });
|
||||
});
|
||||
}, 120);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [employee, filter, open, orgId, trimmed]);
|
||||
}, [employeeId, filter, open, orgId, trimmed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -83,6 +97,19 @@ export function SearchDialog({
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
// The next page joins the shown one only if the words are still the same.
|
||||
const showMore = () => {
|
||||
if (!current || loadingMore) return;
|
||||
const asked = key;
|
||||
setLoadingMore(true);
|
||||
void ask(trimmed, current.results.length).then((result) => {
|
||||
setLoadingMore(false);
|
||||
setAnswer((shown) => withNextPage(shown, asked, result.ok ? { page: result.data } : { error: result.error.message }));
|
||||
}, (reason: unknown) => {
|
||||
setLoadingMore(false);
|
||||
setAnswer((shown) => withNextPage(shown, asked, { error: reason instanceof Error ? reason.message : String(reason) }));
|
||||
});
|
||||
};
|
||||
const openResult = (result: SearchResultRecord) => {
|
||||
const target = targetFor(result);
|
||||
onNavigate(target.route, target.focus);
|
||||
@@ -144,6 +171,17 @@ export function SearchDialog({
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{result.facets.type}</span>
|
||||
</button>
|
||||
))}
|
||||
{current && !current.error && current.total === 0 && (
|
||||
<div role="status" style={{ padding: 8, fontSize: 13, color: "var(--text-3)" }}>{t("Nothing matches “{words}”.", { words: trimmed })}</div>
|
||||
)}
|
||||
{current && current.total > 0 && (
|
||||
<div className="row" style={{ padding: "6px 8px", fontSize: 12, color: "var(--text-3)" }}>
|
||||
<span role="status">{t("Showing {shown} of {total}", { shown: current.results.length, total: current.total })}</span>
|
||||
{current.results.length < current.total && (
|
||||
<span style={{ marginLeft: "auto" }}><Button size="small" onClick={showMore} disabled={loadingMore}>{loadingMore ? t("Loading…") : t("Show more")}</Button></span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SearchPage, SearchResultRecord } from "../../../shared/domain";
|
||||
|
||||
/** The answer shown for one set of words: its hits so far, how many there are, and the last failure, if any. */
|
||||
export interface SearchAnswer {
|
||||
key: string;
|
||||
results: SearchResultRecord[];
|
||||
total: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Adds a later page to the answer it was asked for; a page for other words is dropped, and a success clears an earlier failure. */
|
||||
export function withNextPage(shown: SearchAnswer | null, asked: string, outcome: { page: SearchPage } | { error: string }): SearchAnswer | null {
|
||||
if (!shown || shown.key !== asked) return shown;
|
||||
if ("error" in outcome) return { ...shown, error: outcome.error };
|
||||
return { key: shown.key, results: [...shown.results, ...outcome.page.results], total: outcome.page.total, error: null };
|
||||
}
|
||||
@@ -48,4 +48,6 @@ export interface SearchInput {
|
||||
query: string;
|
||||
entityTypes?: SearchEntityType[];
|
||||
limit?: number;
|
||||
/** Hits to skip, for the next page. */
|
||||
offset?: number;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { SpaceHistoryPage, ThreadHistory } from "./chat-domain";
|
||||
import type { RunRequestRecord } from "./provider-runtime-domain";
|
||||
import type { ApprovalDrift } from "./approval-pin";
|
||||
import type { DesktopResult } from "./result";
|
||||
import type { AppSnapshot, SearchResultRecord } from "./domain";
|
||||
import type { AppSnapshot, SearchPage } from "./domain";
|
||||
import type { ProviderModelEntry } from "./provider-models";
|
||||
import type {
|
||||
BootstrapOnboardingInput,
|
||||
@@ -238,7 +238,7 @@ export interface DesktopApi {
|
||||
runHistory(input: { orgId: string; beforeRunId: string | null } & ({ limit: number } | { from: RunCursor })): Promise<DesktopResult<RunHistoryPage>>;
|
||||
chatSpaceHistory(input: { orgId: string; spaceId: string; beforeMessageId: string | null } & ({ limit: number } | { fromMessageId: string })): Promise<DesktopResult<SpaceHistoryPage>>;
|
||||
revokeConnector(input: RevokeConnectorInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
|
||||
search(input: SearchInput): Promise<DesktopResult<SearchPage>>;
|
||||
subscribe(callback: (event: RendererEvent) => void): () => void;
|
||||
platform: "win32" | "darwin" | "linux";
|
||||
language: "en" | "zh";
|
||||
|
||||
@@ -9,7 +9,7 @@ export type { ProviderAuthProfileRecord, ProviderAuthSessionRecord } from "./pro
|
||||
export { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, type ProviderDataPolicy, type ProviderExecutionMode } from "./provider-policy-domain";
|
||||
export { PROVIDER_REQUEST_STATE, type ProviderRequestRecord, type ProviderRequestState, type ProviderStreamRendererEvent } from "./provider-request-domain";
|
||||
export type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } from "./provider-runtime-domain";
|
||||
export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain";
|
||||
export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchPage, type SearchResultRecord } from "./search-domain";
|
||||
export type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord, SocialAuditRecord } from "./social-domain";
|
||||
export type { AppSnapshot, UiLanguage, UiPreferences, UiTheme } from "./snapshot-domain";
|
||||
export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const;
|
||||
|
||||
@@ -16,6 +16,12 @@ export const SEARCH_ENTITY_TYPES = [
|
||||
|
||||
export type SearchEntityType = (typeof SEARCH_ENTITY_TYPES)[number];
|
||||
|
||||
/** One page of the hits the person may read, and how many there are in all. */
|
||||
export interface SearchPage {
|
||||
results: SearchResultRecord[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SearchResultRecord {
|
||||
entityType: SearchEntityType;
|
||||
entityId: string;
|
||||
|
||||
Reference in new issue
Block a user