Skip to content
Draft
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
8 changes: 8 additions & 0 deletions src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,12 @@ export const ExistingFlags: ConfigFlags = {
default: true,
category: "beta",
},

["component-search-v2"]: {
name: "Component Search V2",
description:
"Show the experimental Components V2 page in the dashboard. Uses placeholder data for now.",
default: false,
category: "beta",
},
};
122 changes: 122 additions & 0 deletions src/hooks/useComponentSearchSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useSyncExternalStore } from "react";

/**
* Bring-your-own-key configuration for the Components V2 natural-language
* search. Stored in localStorage so each user holds their own credentials —
* we ship no shared API key in the bundle.
*
* SECURITY NOTE: localStorage is per-origin and readable by any JS running on
* this origin. It is not encrypted. This is the same trust model as every
* other BYOK web tool — users should generate scoped keys with limited
* permissions and rotate them if compromised.
*/

const STORAGE_KEY = "tangle.componentSearchV2.config";

export interface ComponentSearchConfig {
apiBase: string;
apiKey: string;
/** Fast / default model. */
model: string;
/** Better-quality model used when the "Thinking" toggle is on. */
thinkingModel: string;
}

const DEFAULTS: ComponentSearchConfig = {
apiBase: "",
apiKey: "",
model: "gemini-2.5-flash-lite",
thinkingModel: "gpt-5-mini",
};

function readStoredConfig(): ComponentSearchConfig {
if (typeof window === "undefined") return DEFAULTS;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULTS;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return DEFAULTS;
const p = parsed as Partial<ComponentSearchConfig>;
return {
apiBase: typeof p.apiBase === "string" ? p.apiBase : DEFAULTS.apiBase,
apiKey: typeof p.apiKey === "string" ? p.apiKey : DEFAULTS.apiKey,
model:
typeof p.model === "string" && p.model.trim().length > 0
? p.model
: DEFAULTS.model,
thinkingModel:
typeof p.thinkingModel === "string" && p.thinkingModel.trim().length > 0
? p.thinkingModel
: DEFAULTS.thinkingModel,
};
} catch {
return DEFAULTS;
}
}

/**
* Subscribe to localStorage changes so multiple tabs (or the settings page +
* the search page in the same tab via the manual dispatchEvent below) stay
* in sync.
*/
function subscribe(callback: () => void): () => void {
if (typeof window === "undefined") return () => {};
const handler = (event: StorageEvent) => {
if (event.key === STORAGE_KEY || event.key === null) callback();
};
const localHandler = () => callback();
window.addEventListener("storage", handler);
window.addEventListener("tangle:component-search-config", localHandler);
return () => {
window.removeEventListener("storage", handler);
window.removeEventListener("tangle:component-search-config", localHandler);
};
}

/**
* Stable snapshot. We memoize by JSON string so `useSyncExternalStore`'s
* reference equality check doesn't tear; the JSON form changes if and only
* if the parsed config changes.
*/
let cachedJSON = "";
let cachedConfig: ComponentSearchConfig | null = null;
function getSnapshot(): ComponentSearchConfig {
const fresh = readStoredConfig();
const json = JSON.stringify(fresh);
if (json !== cachedJSON) {
cachedJSON = json;
cachedConfig = fresh;
}
return cachedConfig ?? fresh;
}

function getServerSnapshot(): ComponentSearchConfig {
return DEFAULTS;
}

export function useComponentSearchSettings() {
const config = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);

// The React Compiler memoizes these for us; no useCallback needed.
const update = (partial: Partial<ComponentSearchConfig>) => {
if (typeof window === "undefined") return;
const next: ComponentSearchConfig = { ...config, ...partial };
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
// Notify same-tab subscribers (the `storage` event only fires across tabs).
window.dispatchEvent(new Event("tangle:component-search-config"));
};

const clear = () => {
if (typeof window === "undefined") return;
window.localStorage.removeItem(STORAGE_KEY);
window.dispatchEvent(new Event("tangle:component-search-config"));
};

const isConfigured = config.apiBase.length > 0 && config.apiKey.length > 0;

return { config, update, clear, isConfigured };
}
43 changes: 43 additions & 0 deletions src/hooks/useNaturalLanguageComponentSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useMutation } from "@tanstack/react-query";

import { useComponentSearchSettings } from "@/hooks/useComponentSearchSettings";
import {
type RerankCandidate,
rerankComponentsByNaturalLanguage,
type RerankResult,
} from "@/services/naturalLanguageComponentSearchService";

interface RerankVariables {
query: string;
candidates: RerankCandidate[];
}

/**
* Trigger an LLM rerank of pre-filtered candidates. Modeled as a mutation
* rather than a query because rerank is **explicitly initiated** by the user
* ("Smart Search" button), not automatic on every keystroke — that would
* burn tokens and add latency to the typeahead experience.
*
* The lexical index (see `componentSearchIndex.ts`) is what powers live
* search. Rerank is the optional, opt-in step when judgment matters more
* than literal matching.
*/
export function useNaturalLanguageComponentRerank() {
const { config, isConfigured } = useComponentSearchSettings();

// Prefer the thinking model for rerank — rerank is the moment we *want*
// careful judgment, and the payload is small enough that latency is fine.
// Fall back to the default model when no thinking model is configured.
const model = config.thinkingModel || config.model;

const mutation = useMutation<RerankResult, Error, RerankVariables>({
mutationFn: ({ query, candidates }) =>
rerankComponentsByNaturalLanguage(query, candidates, {
model,
apiBase: config.apiBase,
apiKey: config.apiKey,
}),
});

return { ...mutation, isConfigured };
}
Loading
Loading