summaryrefslogtreecommitdiffhomepage
path: root/src/features/search-settings/logic/view-model.ts
blob: 5d172c9490fae260e9d7eacd14334c36ab5bf295 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
/**
 * Pure core for the search-settings feature — zero DOM, zero effects, zero
 * Svelte.
 *
 * The "Search backend" dropdown (Settings pane) picks the engine backing the
 * `web_search` tool's SEARCH mode. The selection is GLOBAL and persisted on
 * the backend (`GET/PUT /search/config`); decided entries are exactly
 * "Firecrawl (self-hosted)" + "Umans (Exa)" plus "None (search disabled)" as
 * the off state. Scrape/crawl always route to the self-hosted Firecrawl
 * backend regardless of the selection; the tool's `map` mode is removed
 * entirely (backend plan).
 */

/**
 * One selectable backend as the backend's wire advertises it (mirrors
 * `@dispatch/wire`'s `SearchBackendAvailability`).
 */
export interface SearchBackendAvailability {
  readonly id: string;
  readonly label: string;
  /** The availability gate (umans: `UMANS_API_KEY` resolves; firecrawl: always). */
  readonly configured: boolean;
}

/** The global search-backend config (`GET /search/config` response). */
export interface SearchBackendConfig {
  /** The selected backend id, or `"none"` (search off). */
  readonly backend: string;
  readonly available: readonly SearchBackendAvailability[];
}

// ── Injected ports (consumer-defines-port; the composition root adapts the
//    store's HTTP calls to these shapes). ──────────────────────────────────────

/** Outcome of `GET/PUT /search/config`. `unavailable` marks the 503 degrade
 * (the backend's search-config service not loaded) so the UI can disable the
 * field with a "search not available" hint instead of a generic error. */
export type SearchBackendConfigResult =
  | { readonly ok: true; readonly config: SearchBackendConfig }
  | { readonly ok: false; readonly error: string; readonly unavailable?: boolean };

export type LoadSearchBackendConfig = () => Promise<SearchBackendConfigResult>;

export type SaveSearchBackend = (backend: string) => Promise<SearchBackendConfigResult>;

/** The explicit off-state option label. */
export const NONE_BACKEND_ID = "none";
export const NONE_BACKEND_LABEL = "None (search disabled)";

/** One rendered `<option>` of the dropdown. */
export interface SearchBackendOption {
  readonly id: string;
  readonly label: string;
  /** Unconfigured backends stay LISTED but disabled — never an error. */
  readonly disabled: boolean;
}

/**
 * Build the dropdown options from the config: every advertised backend in
 * wire order (unconfigured ones disabled with a "not configured" suffix),
 * then the "None (search disabled)" off state appended last. If the persisted
 * selection is not among them (an unknown id from a newer/older backend), it
 * is prepended so the select can render it truthfully.
 */
export function buildSearchBackendOptions(
  config: SearchBackendConfig,
): readonly SearchBackendOption[] {
  const options: SearchBackendOption[] = config.available.map((b) => ({
    id: b.id,
    label: b.configured ? b.label : `${b.label} (not configured)`,
    disabled: !b.configured,
  }));
  if (
    config.backend !== NONE_BACKEND_ID &&
    !config.available.some((b) => b.id === config.backend)
  ) {
    options.unshift({ id: config.backend, label: `${config.backend} (unknown)`, disabled: false });
  }
  options.push({ id: NONE_BACKEND_ID, label: NONE_BACKEND_LABEL, disabled: false });
  return options;
}

/** The human-facing label for a backend id (the saved / error messages). */
export function searchBackendLabel(config: SearchBackendConfig, id: string): string {
  if (id === NONE_BACKEND_ID) {
    return NONE_BACKEND_LABEL;
  }
  return config.available.find((b) => b.id === id)?.label ?? id;
}

/** Whether selecting `selected` would change the persisted backend. */
export function searchBackendChanged(selected: string, persisted: string): boolean {
  return selected !== persisted;
}

/**
 * Normalize the untyped JSON at the network seam so a malformed/partial
 * response can never crash the renderer (mirrors `normalizeHeartbeatConfig`).
 * Unknown entries degrade to unconfigured; a missing backend falls back to
 * `"none"` rendering (the safest default for a UI that must not lie).
 */
export function normalizeSearchBackendConfig(raw: unknown): SearchBackendConfig {
  const obj = (raw ?? {}) as {
    backend?: unknown;
    available?: unknown;
  };
  const available = Array.isArray(obj.available) ? obj.available : [];
  return {
    backend:
      typeof obj.backend === "string" && obj.backend.length > 0 ? obj.backend : NONE_BACKEND_ID,
    available: available.flatMap((entry) => {
      if (entry === null || typeof entry !== "object") return [];
      const e = entry as { id?: unknown; label?: unknown; configured?: unknown };
      if (typeof e.id !== "string" || e.id.length === 0) return [];
      return [
        {
          id: e.id,
          label: typeof e.label === "string" ? e.label : e.id,
          configured: e.configured === true,
        },
      ];
    }),
  };
}