blob: 62c50cb688071caca74c0bcda166eff500bdc1fa [file]
import { useRef, useState } from 'react';
import { Banner, EmptyState, Link } from '@astryxdesign/core';
import type { AppSettings, UpdateAppSettingsResult } from '@maka/core/settings';
import type { WebSearchCredentialStatus } from '@maka/core/web-search';
import { normalizeSearchUrl } from '@maka/core/search';
import { webSearchCredentialStatusFromResponse } from '@maka/core/web-search';
import { Button, Selector, StatusDot, TextInput, RelativeTime, Switch, redactSecrets, useMountedRef, useToast, useUiLocale } from '@maka/ui';
import { getWebSearchSettingsCopy, type WebSearchSettingsCopy } from '../locales/settings-web-search-copy';
import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js';
import { SettingsActions, SettingsField, SettingsPage, SettingsRow, SettingsSection } from './settings-section';
import { PasswordInput } from './password-input';
import { settingsActionErrorMessage } from './settings-error-copy';
import { dotForStatus, type StatusSemantic } from '@maka/ui';
import { useKeyedActionGuard } from './use-action-guard';
/**
* PR-WEB-SEARCH-TAVILY-0: Settings → Web search.
*
* Current provider support is Tavily only. Renderer never sees the cleartext API
* key — `props.settings.webSearch.providers.tavily.apiKey` arrives
* pre-masked from the IPC store boundary (the bullet sentinel
* `MASKED_TOKEN_SENTINEL`). Re-submitting the sentinel is treated as
* "keep current" in `mergeWebSearchSettings`.
*
* The test button calls Runtime Host `web-search.execute` (a Tavily request)
* and surfaces ok/fail via toast. The live-query verifier runs a real query
* and renders 3-5 plain-text rows.
*/
export function WebSearchSettingsPage(props: {
settings: AppSettings;
onUpdate(patch: Parameters<typeof window.maka.settings.update>[0]): Promise<UpdateAppSettingsResult>;
}) {
const locale = useUiLocale();
const copy = getWebSearchSettingsCopy(locale);
const sharedCopy = getSettingsSharedCopy(locale);
const webSearch = props.settings.webSearch;
const usingModelSearch = webSearch.defaultProvider === 'model';
const tavily = webSearch.providers.tavily;
const tavilyKey = tavily.apiKey;
const credentialSource = tavily.credentialSource;
const usingEnvKey = credentialSource === 'env';
const [draftKey, setDraftKey] = useState('');
const [pendingWebSearchEnabled, setPendingWebSearchEnabled] = useState(false);
const [pendingCredentialAction, setPendingCredentialAction] = useState<'save' | 'clear' | null>(null);
const [testing, setTesting] = useState(false);
const [liveQuery, setLiveQuery] = useState('');
const [liveQueryRunning, setLiveQueryRunning] = useState(false);
const [liveQueryResults, setLiveQueryResults] = useState<readonly { title: string; url: string; snippet: string; source: string }[] | null>(null);
const [liveQueryError, setLiveQueryError] = useState<string | null>(null);
const webSearchMountedRef = useMountedRef();
const webSearchActionGuard = useKeyedActionGuard<'set-enabled' | 'credential' | 'test' | 'live-query'>();
const liveQueryInputRef = useRef(liveQuery);
const toast = useToast();
function updateLiveQuery(next: string) {
liveQueryInputRef.current = next;
setLiveQuery(next);
setLiveQueryError(null);
setLiveQueryResults(null);
}
function isCurrentLiveQuery(queryOwner: string): boolean {
return webSearchMountedRef.current && liveQueryInputRef.current === queryOwner;
}
async function runCredentialAction(action: 'save' | 'clear', run: () => Promise<void>) {
if (webSearchActionGuard.has('credential') || webSearchActionGuard.has('test')) return;
const releaseCredential = webSearchActionGuard.begin('credential');
if (!releaseCredential) return;
setPendingCredentialAction(action);
try {
await run();
} finally {
releaseCredential();
if (webSearchMountedRef.current) {
setPendingCredentialAction(null);
}
}
}
async function updateWebSearch(
patch: NonNullable<Parameters<typeof window.maka.settings.update>[0]['webSearch']>,
failureTitle = copy.saveFailed,
): Promise<boolean> {
try {
await props.onUpdate({ webSearch: patch });
return true;
} catch (error) {
if (webSearchMountedRef.current) {
toast.error(failureTitle, settingsActionErrorMessage(error, locale));
}
return false;
}
}
async function setEnabled(enabled: boolean) {
const releaseEnabled = webSearchActionGuard.begin('set-enabled');
if (!releaseEnabled) return;
setPendingWebSearchEnabled(true);
try {
await updateWebSearch({ enabled });
} finally {
releaseEnabled();
if (webSearchMountedRef.current) {
setPendingWebSearchEnabled(false);
}
}
}
async function persistCredentialStatus(status: WebSearchCredentialStatus, credentialVersion: number): Promise<boolean> {
return updateWebSearch(
{
providers: {
tavily: {
credentialVersion,
credentialStatus: status,
credentialCheckedAt: new Date().toISOString(),
},
},
},
copy.saveStatusFailed,
);
}
async function saveDraftKey() {
if (usingEnvKey || draftKey.length === 0) return;
await runCredentialAction('save', async () => {
const saved = await updateWebSearch({ providers: { tavily: { apiKey: draftKey } } });
if (!saved) return;
if (!webSearchMountedRef.current) return;
setDraftKey('');
toast.success(copy.keySaved, copy.keySavedDetail);
});
}
async function clearKey() {
await runCredentialAction('clear', async () => {
const saved = await updateWebSearch({ enabled: false, providers: { tavily: { apiKey: '' } } });
if (!saved) return;
if (!webSearchMountedRef.current) return;
setDraftKey('');
toast.success(copy.credentialsCleared, copy.credentialsClearedDetail);
});
}
async function runTest() {
if (webSearchActionGuard.has('test') || webSearchActionGuard.has('credential')) return;
const releaseTest = webSearchActionGuard.begin('test');
if (!releaseTest) return;
setTesting(true);
const usesDraftKey = !usingModelSearch && draftKey.trim().length > 0;
const testedCredentialVersion = tavily.credentialVersion;
try {
const result = await window.maka.webSearch.test({
provider: webSearch.defaultProvider,
apiKey: usesDraftKey ? draftKey : undefined,
});
if (!webSearchMountedRef.current) return;
if (!usingModelSearch && !usesDraftKey && hasUsableKey) {
void persistCredentialStatus(webSearchCredentialStatusFromResponse(result), testedCredentialVersion);
}
if (result.ok) {
toast.success(copy.credentialValid, copy.resultCount(result.results.length));
} else {
toast.error(copy.testFailed, copy.errors[result.reason]);
}
} catch (err) {
if (webSearchMountedRef.current) {
toast.error(copy.testError, settingsActionErrorMessage(err, locale));
}
} finally {
releaseTest();
if (webSearchMountedRef.current) {
setTesting(false);
}
}
}
async function runLiveQuery() {
if (webSearchActionGuard.has('live-query')) return;
const queryOwner = liveQueryInputRef.current;
const trimmed = queryOwner.trim();
if (trimmed.length === 0) return;
const releaseLiveQuery = webSearchActionGuard.begin('live-query');
if (!releaseLiveQuery) return;
setLiveQueryRunning(true);
setLiveQueryError(null);
setLiveQueryResults(null);
const queriedCredentialVersion = tavily.credentialVersion;
try {
const result = await window.maka.webSearch.query({
provider: webSearch.defaultProvider,
query: trimmed,
limit: 5,
});
if (!isCurrentLiveQuery(queryOwner)) return;
if (result.ok) {
setLiveQueryResults(result.results);
if (!usingModelSearch && hasUsableKey) {
void persistCredentialStatus('valid', queriedCredentialVersion);
}
} else {
setLiveQueryError(copy.errors[result.reason]);
if (!usingModelSearch && hasUsableKey) {
void persistCredentialStatus(webSearchCredentialStatusFromResponse(result), queriedCredentialVersion);
}
}
} catch (err) {
if (isCurrentLiveQuery(queryOwner)) {
setLiveQueryError(settingsActionErrorMessage(err, locale));
}
} finally {
releaseLiveQuery();
if (webSearchMountedRef.current) {
setLiveQueryRunning(false);
}
}
}
const hasStoredKey = tavilyKey.length > 0;
const hasUsableKey = hasStoredKey || usingEnvKey;
const hasUsableProvider = usingModelSearch || hasUsableKey;
const statusCopy = usingModelSearch
? {
label: webSearch.enabled ? copy.statuses.modelEnabled : copy.statuses.modelDisabled,
// Verified-and-on is proven health, which is what success is for.
// Off stays amber (`attention`) exactly as before — it was never one of
// the ruled `info` states, and this pass repaints nothing it did not name.
tone: webSearch.enabled ? ('success' as const) : ('attention' as const),
}
: presentWebSearchCredentialStatus(
credentialSource,
webSearch.enabled,
tavily.credentialStatus,
copy,
);
const queryDisabledReason = webSearchQueryDisabledReason({
hasUsableKey: hasUsableProvider,
enabled: webSearch.enabled,
query: liveQuery,
copy,
});
const checkedAtMs = tavily.credentialCheckedAt
? Date.parse(tavily.credentialCheckedAt)
: Number.NaN;
const hasCheckedAt = !usingModelSearch && Number.isFinite(checkedAtMs);
const credentialActionBusy = pendingCredentialAction !== null || testing;
return (
<SettingsPage>
<SettingsSection
title={sharedCopy.groups.searchProvider}
description={sharedCopy.groups.searchProviderHelp}
>
<SettingsRow
label={copy.provider}
description={copy.providerHelp}
end={<Selector
value={webSearch.defaultProvider}
label={copy.provider}
isLabelHidden
options={[
{ value: 'model', label: copy.providerModel },
{ value: 'tavily', label: copy.providerTavily },
]}
onChange={(value) =>
void updateWebSearch({
defaultProvider: value === 'tavily' ? 'tavily' : 'model',
})
}
/>}
/>
<SettingsRow
label={copy.enabled}
description={copy.enabledHelp}
align="start"
end={<div className="settingsWebSearchControlCluster">
<div className="settingsWebSearchStatusCluster" role="group" aria-label={copy.statusAria}>
<span className="settingsStatus">
<StatusDot variant={dotForStatus(statusCopy.tone)} label={statusCopy.label} />
<span>{statusCopy.label}</span>
</span>
{hasCheckedAt && (
<small>
{copy.lastTest}<RelativeTime ts={checkedAtMs} />
</small>
)}
<small>
{usingModelSearch
? copy.sources.model
: presentWebSearchCredentialSource(credentialSource, hasStoredKey, copy)}
</small>
</div>
<Switch
label={copy.enabledAria}
isLabelHidden
value={webSearch.enabled}
isDisabled={!hasUsableProvider || pendingWebSearchEnabled}
onChange={(enabled) => void setEnabled(enabled)}
/>
</div>}
/>
{!usingModelSearch && <>{/* The key was an input squeezed into the row's end slot with the
actions posing as a second labeled row. Astryx's own form idiom:
a full-width credential Field, then the section's one action
cluster (save primary, test secondary, clear ghost). */}
<SettingsField>
<PasswordInput
value={draftKey}
onChange={setDraftKey}
isDisabled={usingEnvKey || credentialActionBusy}
placeholder={usingEnvKey ? copy.envPlaceholder : hasStoredKey ? copy.storedPlaceholder : copy.keyPlaceholder}
label={copy.key}
description={usingEnvKey ? copy.envKeyHelp : copy.savedKeyHelp}
/>
{!usingEnvKey && (
<small className="settingsQuietStatus">
<Link href="https://tavily.com" target="_blank" rel="noreferrer noopener">tavily.com</Link>
</small>
)}
</SettingsField>
<SettingsActions role="group" aria-label={copy.actions}>
<Button
variant="primary"
isDisabled={credentialActionBusy || usingEnvKey || draftKey.length === 0}
onClick={() => void saveDraftKey()}
label={pendingCredentialAction === 'save' ? copy.saving : copy.saveKey}
/>
<Button
variant="secondary"
isDisabled={credentialActionBusy || (draftKey.length === 0 && !hasUsableKey)}
onClick={() => void runTest()}
label={testing ? copy.testing : copy.testKey}
/>
{hasStoredKey && (
<Button
variant="ghost"
isDisabled={credentialActionBusy}
onClick={() => void clearKey()}
label={pendingCredentialAction === 'clear' ? copy.clearing : copy.clearKey}
/>
)}
</SettingsActions>
</>}
{usingModelSearch && (
<SettingsRow label={copy.modelCredential} description={copy.modelCredentialHelp} />
)}
</SettingsSection>
{!usingModelSearch && (
<SettingsSection
title={sharedCopy.groups.searchBehavior}
description={sharedCopy.groups.searchBehaviorHelp}
>
{/* UX audit (owner msg `30f736ed`): one action wore three labels.
Keep one label on the full-width query field. */}
<SettingsField>
<TextInput
value={liveQuery}
onChange={(value) => updateLiveQuery(value)}
placeholder={copy.queryPlaceholder}
label={copy.testSearch}
description={copy.testSearchHelp}
width="100%"
onKeyDown={(event) => {
if (event.key === 'Enter' && !liveQueryRunning) {
event.preventDefault();
void runLiveQuery();
}
}}
/>
</SettingsField>
<SettingsActions>
<div className="settingsWebSearchSearchControls">
<Button
variant="primary"
isLoading={liveQueryRunning}
isDisabled={queryDisabledReason !== null}
onClick={() => void runLiveQuery()}
label={copy.search}
/>
{!liveQueryRunning && queryDisabledReason && (
<small className="settingsWebSearchDisabledReason">{queryDisabledReason}</small>
)}
</div>
</SettingsActions>
</SettingsSection>
)}
{liveQueryError && (
<Banner status="error" role="alert" title={copy.queryFailed(liveQueryError)} />
)}
{(() => {
// PR-SETTINGS-WEB-SEARCH-URL-HARDEN-0: match the chat-side
// WebSearchPreview hardening (xuan `e511aa5`): the renderer
// does NOT trust raw URLs / text coming back over IPC even
// though the main-process Tavily client filters first. Drop
// non-http(s) / malformed rows and redact every text cell
// before it reaches the DOM.
const safeRows: ReadonlyArray<{ title: string; url: string; source: string; snippet: string }> | null =
liveQueryResults
? liveQueryResults
.map((row) => {
const normalized = normalizeSearchUrl(row.url);
if (!normalized.ok) return null;
return {
title: redactSecrets(row.title),
url: redactSecrets(normalized.value),
source: redactSecrets(row.source),
snippet: redactSecrets(row.snippet),
};
})
.filter(
(
row,
): row is { title: string; url: string; source: string; snippet: string } =>
row !== null,
)
: null;
if (safeRows && safeRows.length === 0 && !liveQueryError) {
return <EmptyState isCompact title={copy.noResults} />;
}
if (safeRows && safeRows.length > 0) {
return (
<ul className="settingsWebSearchResults" aria-label={copy.resultsAria}>
{safeRows.map((row, idx) => (
<li key={`${row.url}-${idx}`} className="settingsWebSearchResult">
<Link href={row.url} target="_blank" rel="noreferrer noopener">{row.title}</Link>
<small>{row.source}</small>
<p>{row.snippet}</p>
</li>
))}
</ul>
);
}
return null;
})()}
</SettingsPage>
);
}
function webSearchQueryDisabledReason(input: { hasUsableKey: boolean; enabled: boolean; query: string; copy: WebSearchSettingsCopy }): string | null {
if (!input.hasUsableKey) return input.copy.disabledReasons.noKey;
if (!input.enabled) return input.copy.disabledReasons.disabled;
if (input.query.trim().length === 0) return input.copy.disabledReasons.noQuery;
return null;
}
function presentWebSearchCredentialStatus(
credentialSource: AppSettings['webSearch']['providers']['tavily']['credentialSource'],
enabled: boolean,
status: WebSearchCredentialStatus,
copy: WebSearchSettingsCopy,
): { label: string; tone: StatusSemantic } {
if (credentialSource === 'none') return { label: copy.statuses.not_configured, tone: 'attention' };
if (status === 'valid') {
return enabled
? { label: copy.statuses.validEnabled, tone: 'success' }
// Valid credentials, feature off: a fact the user set, not a problem.
: { label: copy.statuses.validDisabled, tone: 'neutral' };
}
if (status === 'invalid_credentials') return { label: copy.statuses.invalid_credentials, tone: 'error' };
if (status === 'rate_limited') return { label: copy.statuses.rate_limited, tone: 'attention' };
if (status === 'timeout') return { label: copy.statuses.timeout, tone: 'attention' };
if (status === 'network_error') return { label: copy.statuses.network_error, tone: 'attention' };
if (status === 'not_configured') return { label: copy.statuses.not_configured, tone: 'attention' };
return enabled
? { label: copy.statuses.unknownEnabled, tone: 'attention' }
// Configured but never tested: setup is unfinished, and the amber says so.
// Testing moves it to success or error, completing the narrative; neutral
// would read as "all set" and break that story.
: { label: copy.statuses.untested, tone: 'attention' };
}
function presentWebSearchCredentialSource(
credentialSource: AppSettings['webSearch']['providers']['tavily']['credentialSource'],
hasStoredKey: boolean,
copy: WebSearchSettingsCopy,
): string {
if (credentialSource === 'env') {
return hasStoredKey ? copy.sources.envWithSaved : copy.sources.env;
}
if (credentialSource === 'saved') return copy.sources.saved;
return copy.sources.none;
}