diff --git a/src/components/config/VisualConfigEditor.module.scss b/src/components/config/VisualConfigEditor.module.scss
index 254ae277..efe72fcf 100644
--- a/src/components/config/VisualConfigEditor.module.scss
+++ b/src/components/config/VisualConfigEditor.module.scss
@@ -211,6 +211,116 @@
box-shadow: 0 1px 2px color-mix(in srgb, var(--text-primary) 10%, transparent);
}
+// ── Global field search (P1) ────────────────────────────────────────────────
+.searchBox {
+ position: relative;
+ min-width: 0;
+}
+
+.searchControl {
+ padding-right: 38px !important;
+}
+
+.searchIcon {
+ display: inline-flex;
+ align-items: center;
+ color: var(--text-tertiary);
+ pointer-events: none;
+}
+
+.searchResults {
+ position: absolute;
+ z-index: 20;
+ top: calc(100% + 6px);
+ left: 0;
+ right: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ max-height: 320px;
+ overflow-y: auto;
+ padding: 6px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: var(--bg-primary);
+ box-shadow: 0 14px 32px color-mix(in srgb, var(--text-primary) 16%, transparent);
+}
+
+.searchResultItem {
+ @include button-reset;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ width: 100%;
+ padding: 9px 10px;
+ border-radius: 6px;
+ text-align: left;
+ transition: background-color 0.12s ease;
+
+ &:hover {
+ background: color-mix(in srgb, var(--text-primary) 6%, transparent);
+ }
+}
+
+.searchResultLabel {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 7px;
+ min-width: 0;
+ color: var(--text-primary);
+ font-size: 13px;
+ font-weight: 650;
+ line-height: 1.3;
+}
+
+.searchResultQualifier {
+ color: var(--text-tertiary);
+ font-size: 11px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.searchResultSection {
+ flex: 0 0 auto;
+ color: var(--text-secondary);
+ font-size: 11px;
+ font-weight: 650;
+ white-space: nowrap;
+}
+
+.searchEmpty {
+ padding: 12px 10px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ text-align: center;
+}
+
+// Stateless wrapper around each searchable field; the search jump scrolls to and
+// pulse-highlights it.
+.fieldAnchor {
+ display: block;
+ min-width: 0;
+ scroll-margin-top: calc(var(--header-height, 64px) + 16px);
+}
+
+.fieldHighlightActive {
+ border-radius: 10px;
+ animation: cfgFieldHighlight 1.8s ease-out;
+}
+
+@keyframes cfgFieldHighlight {
+ 0% {
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--text-primary) 55%, transparent);
+ }
+ 55% {
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--text-primary) 32%, transparent);
+ }
+ 100% {
+ box-shadow: 0 0 0 3px transparent;
+ }
+}
+
.simpleView {
display: flex;
flex-direction: column;
diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx
index 179a1d07..92a8d02c 100644
--- a/src/components/config/VisualConfigEditor.tsx
+++ b/src/components/config/VisualConfigEditor.tsx
@@ -20,6 +20,7 @@ import {
IconNetwork,
IconSatellite,
IconScrollText,
+ IconSearch,
IconShield,
IconSlidersHorizontal,
IconTimer,
@@ -42,17 +43,14 @@ import {
PayloadRulesEditor,
StringListEditor,
} from './VisualConfigEditorBlocks';
+import {
+ configFieldDomId,
+ searchConfigFields,
+ type ConfigFieldSearchEntry,
+ type VisualSectionId,
+} from './configSearchIndex';
import styles from './VisualConfigEditor.module.scss';
-type VisualSectionId =
- | 'connectivity'
- | 'network'
- | 'logging'
- | 'quota'
- | 'streaming'
- | 'advanced'
- | 'payload';
-
type EditorMode = 'simple' | 'full';
const EDITOR_MODE_STORAGE_KEY = 'config-management:editor-mode';
@@ -112,6 +110,16 @@ function Divider() {
return
;
}
+// Stable, stateless anchor around a searchable field. Search jumps target its DOM id
+// (see configSearchIndex.ts) and the highlight pulse is applied to it imperatively.
+function FieldAnchor({ fieldId, children }: { fieldId: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
function SectionSubsection({
title,
description,
@@ -202,12 +210,98 @@ export function VisualConfigEditor({
const mobileNavButtonRefs = useRef>>(
{}
);
+ const [searchQuery, setSearchQuery] = useState('');
+ // Dropdown visibility is tracked separately from the query text so a jump can close the
+ // results while leaving the typed text in the box for further editing.
+ const [searchOpen, setSearchOpen] = useState(false);
+ // A fresh object per jump; the effect handles it once (guarded by handledJumpRef) so it
+ // never needs to clear state from inside the effect.
+ const [jumpRequest, setJumpRequest] = useState<{
+ fieldId: string;
+ sectionId: VisualSectionId;
+ } | null>(null);
+ const handledJumpRef = useRef<{ fieldId: string; sectionId: VisualSectionId } | null>(null);
+ const searchBoxRef = useRef(null);
+ const highlightTimerRef = useRef(null);
+ const highlightedElRef = useRef(null);
const handleModeChange = useCallback((next: EditorMode) => {
setMode(next);
localStorage.setItem(EDITOR_MODE_STORAGE_KEY, next);
}, []);
+ const searchResults = useMemo(() => searchConfigFields(searchQuery, t), [searchQuery, t]);
+
+ const handleResultJump = useCallback(
+ (entry: ConfigFieldSearchEntry) => {
+ // Keep the query text so the user can tweak it; just close the results dropdown.
+ setSearchOpen(false);
+ handleModeChange('full');
+ setActiveSectionId(entry.sectionId);
+ // A new object instance defers scroll/highlight to the effect below, giving a
+ // simple→full switch time to mount the DOM before we query for the field.
+ setJumpRequest({ fieldId: entry.fieldId, sectionId: entry.sectionId });
+ },
+ [handleModeChange]
+ );
+
+ // Imperatively scroll to and pulse-highlight the jumped-to field once full mode is mounted.
+ useEffect(() => {
+ if (mode !== 'full' || !jumpRequest || handledJumpRef.current === jumpRequest) return;
+ handledJumpRef.current = jumpRequest; // handle each request once, even if deps re-fire
+ const { fieldId, sectionId } = jumpRequest;
+
+ const el = document.getElementById(configFieldDomId(fieldId));
+ if (!el) {
+ // Field not rendered right now (e.g. TLS cert while TLS is disabled) — fall back to section.
+ sectionRefs.current[sectionId]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ return;
+ }
+
+ // Expand the collapsed group this field belongs to: an ancestor when the
+ // anchor sits inside the group (TLS / remote / advanced fields), or a descendant when
+ // the anchor wraps the whole group (payload rule groups).
+ const details = el.closest('details') ?? el.querySelector('details');
+ if (details && !details.open) details.open = true;
+
+ // Clear any in-flight highlight before starting a new one.
+ if (highlightTimerRef.current !== null) {
+ clearTimeout(highlightTimerRef.current);
+ highlightedElRef.current?.classList.remove(styles.fieldHighlightActive);
+ }
+
+ requestAnimationFrame(() => {
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ el.classList.add(styles.fieldHighlightActive);
+ });
+ highlightedElRef.current = el;
+ highlightTimerRef.current = window.setTimeout(() => {
+ el.classList.remove(styles.fieldHighlightActive);
+ highlightTimerRef.current = null;
+ highlightedElRef.current = null;
+ }, 1800);
+ }, [mode, jumpRequest]);
+
+ // Clear the highlight timer on unmount.
+ useEffect(
+ () => () => {
+ if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
+ },
+ []
+ );
+
+ // Close the results dropdown (keeping the query) when clicking outside the search box.
+ useEffect(() => {
+ if (!searchOpen) return;
+ const handlePointerDown = (event: MouseEvent) => {
+ if (searchBoxRef.current && !searchBoxRef.current.contains(event.target as Node)) {
+ setSearchOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handlePointerDown);
+ return () => document.removeEventListener('mousedown', handlePointerDown);
+ }, [searchOpen]);
+
const isKeepaliveDisabled =
values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0';
const isNonstreamKeepaliveDisabled =
@@ -421,75 +515,89 @@ export function VisualConfigEditor({
// Shared high-frequency field blocks — reused verbatim by both the simple view and full mode
// so the two layouts never drift apart.
const portField = (
- onChange({ port: e.target.value })}
- disabled={disabled}
- error={portError}
- />
+
+ onChange({ port: e.target.value })}
+ disabled={disabled}
+ error={portError}
+ />
+
);
const proxyUrlField = (
- onChange({ proxyUrl: e.target.value })}
- disabled={disabled}
- />
+
+ onChange({ proxyUrl: e.target.value })}
+ disabled={disabled}
+ />
+
);
const apiKeysField = (
-
+
+
+
);
const debugToggle = (
- onChange({ debug })}
- />
+
+ onChange({ debug })}
+ />
+
);
const loggingToFileToggle = (
- onChange({ loggingToFile })}
- />
+
+ onChange({ loggingToFile })}
+ />
+
);
const quotaSwitchProjectToggle = (
- onChange({ quotaSwitchProject })}
- />
+
+ onChange({ quotaSwitchProject })}
+ />
+
);
const quotaSwitchPreviewModelToggle = (
- onChange({ quotaSwitchPreviewModel })}
- />
+
+ onChange({ quotaSwitchPreviewModel })}
+ />
+
);
const navContent = (
@@ -566,6 +674,68 @@ export function VisualConfigEditor({
) : null}
+
+
+
{
+ setSearchQuery(e.target.value);
+ setSearchOpen(true);
+ }}
+ onFocus={() => setSearchOpen(true)}
+ onKeyDown={(e) => {
+ // Ignore Enter/keys fired while an IME is composing (e.g. picking a
+ // Chinese candidate) — otherwise candidate selection triggers a jump.
+ if (e.nativeEvent.isComposing) return;
+ if (e.key === 'Escape') {
+ setSearchOpen(false);
+ } else if (e.key === 'Enter' && searchOpen && searchResults.length > 0) {
+ e.preventDefault();
+ handleResultJump(searchResults[0]);
+ }
+ }}
+ rightElement={
+
+
+
+ }
+ />
+ {searchOpen && searchQuery.trim() ? (
+
+ {searchResults.length > 0 ? (
+ searchResults.map((entry) => (
+
handleResultJump(entry)}
+ >
+
+ {t(entry.labelKey)}
+ {entry.qualifierKey ? (
+
+ {t(entry.qualifierKey)}
+
+ ) : null}
+
+
+ {t(`config_management.visual.sections.${entry.sectionId}.title`)}
+
+
+ ))
+ ) : (
+
+ {t('config_management.visual.search.no_results')}
+
+ )}
+
+ ) : null}
+
{mode === 'simple' ? (
@@ -654,24 +824,28 @@ export function VisualConfigEditor({
>
- onChange({ host: e.target.value })}
- disabled={disabled}
- />
+
+ onChange({ host: e.target.value })}
+ disabled={disabled}
+ />
+
{portField}
- onChange({ authDir: e.target.value })}
- disabled={disabled}
- hint={t('config_management.visual.sections.auth.auth_dir_hint')}
- />
+
+ onChange({ authDir: e.target.value })}
+ disabled={disabled}
+ hint={t('config_management.visual.sections.auth.auth_dir_hint')}
+ />
+
{apiKeysField}
@@ -681,32 +855,38 @@ export function VisualConfigEditor({
defaultOpen={false}
>
- onChange({ tlsEnable })}
- />
+
+ onChange({ tlsEnable })}
+ />
+
{values.tlsEnable ? (
<>
- onChange({ tlsCert: e.target.value })}
- disabled={disabled}
- />
- onChange({ tlsKey: e.target.value })}
- disabled={disabled}
- />
+
+ onChange({ tlsCert: e.target.value })}
+ disabled={disabled}
+ />
+
+
+ onChange({ tlsKey: e.target.value })}
+ disabled={disabled}
+ />
+
>
) : null}
@@ -720,56 +900,66 @@ export function VisualConfigEditor({
>
- onChange({ rmAllowRemote })}
- />
- onChange({ rmDisableControlPanel })}
- />
-
- onChange({ rmDisableAutoUpdatePanel })
- }
- />
+
+ onChange({ rmAllowRemote })}
+ />
+
+
+ onChange({ rmDisableControlPanel })}
+ />
+
+
+
+ onChange({ rmDisableAutoUpdatePanel })
+ }
+ />
+
- onChange({ rmSecretKey: e.target.value })}
- disabled={disabled}
- />
- onChange({ rmPanelRepo: e.target.value })}
- disabled={disabled}
- />
+
+ onChange({ rmSecretKey: e.target.value })}
+ disabled={disabled}
+ />
+
+
+ onChange({ rmPanelRepo: e.target.value })}
+ disabled={disabled}
+ />
+
@@ -789,171 +979,207 @@ export function VisualConfigEditor({
{proxyUrlField}
- onChange({ requestRetry: e.target.value })}
- disabled={disabled}
- error={requestRetryError}
- />
- onChange({ maxRetryCredentials: e.target.value })}
- disabled={disabled}
- hint={t('config_management.visual.sections.network.max_retry_credentials_hint')}
- error={maxRetryCredentialsError}
- />
- onChange({ maxRetryInterval: e.target.value })}
- disabled={disabled}
- error={maxRetryIntervalError}
- />
- onChange({ authAutoRefreshWorkers: e.target.value })}
- disabled={disabled}
- hint={t(
- 'config_management.visual.sections.network.auth_auto_refresh_workers_hint'
- )}
- error={authAutoRefreshWorkersError}
- />
-
-
+ onChange({ requestRetry: e.target.value })}
disabled={disabled}
- ariaLabelledBy={routingStrategyLabelId}
- ariaDescribedBy={routingStrategyHintId}
- onChange={(nextValue) =>
- onChange({
- routingStrategy: nextValue as VisualConfigValues['routingStrategy'],
- })
- }
+ error={requestRetryError}
/>
-
-
-
+
+ onChange({ maxRetryCredentials: e.target.value })}
disabled={disabled}
- ariaLabelledBy={disableImageGenerationLabelId}
- ariaDescribedBy={disableImageGenerationHintId}
- onChange={(nextValue) =>
- onChange({
- disableImageGeneration:
- nextValue as VisualConfigValues['disableImageGeneration'],
- })
- }
+ hint={t(
+ 'config_management.visual.sections.network.max_retry_credentials_hint'
+ )}
+ error={maxRetryCredentialsError}
/>
-
- onChange({ gptImage2BaseModel: e.target.value })}
- disabled={disabled}
- hint={t(
- 'config_management.visual.sections.network.gpt_image_2_base_model_hint'
- )}
- />
- onChange({ routingSessionAffinityTTL: e.target.value })}
- disabled={disabled}
- />
+
+
+ onChange({ maxRetryInterval: e.target.value })}
+ disabled={disabled}
+ error={maxRetryIntervalError}
+ />
+
+
+ onChange({ authAutoRefreshWorkers: e.target.value })}
+ disabled={disabled}
+ hint={t(
+ 'config_management.visual.sections.network.auth_auto_refresh_workers_hint'
+ )}
+ error={authAutoRefreshWorkersError}
+ />
+
+
+
+
+ onChange({
+ routingStrategy: nextValue as VisualConfigValues['routingStrategy'],
+ })
+ }
+ />
+
+
+
+
+
+ onChange({
+ disableImageGeneration:
+ nextValue as VisualConfigValues['disableImageGeneration'],
+ })
+ }
+ />
+
+
+
+ onChange({ gptImage2BaseModel: e.target.value })}
+ disabled={disabled}
+ hint={t(
+ 'config_management.visual.sections.network.gpt_image_2_base_model_hint'
+ )}
+ />
+
+
+ onChange({ routingSessionAffinityTTL: e.target.value })}
+ disabled={disabled}
+ />
+
- onChange({ forceModelPrefix })}
- />
- onChange({ passthroughHeaders })}
- />
- onChange({ disableCooling })}
- />
- onChange({ routingSessionAffinity })}
- />
- onChange({ wsAuth })}
- />
- onChange({ enableGeminiCliEndpoint })}
- />
+
+ onChange({ forceModelPrefix })}
+ />
+
+
+ onChange({ passthroughHeaders })}
+ />
+
+
+ onChange({ disableCooling })}
+ />
+
+
+ onChange({ routingSessionAffinity })}
+ />
+
+
+ onChange({ wsAuth })}
+ />
+
+
+ onChange({ enableGeminiCliEndpoint })}
+ />
+
@@ -971,57 +1197,73 @@ export function VisualConfigEditor({
{debugToggle}
- onChange({ commercialMode })}
- />
+
+ onChange({ commercialMode })}
+ />
+
{loggingToFileToggle}
- onChange({ logsMaxTotalSizeMb: e.target.value })}
- disabled={disabled}
- error={logsMaxSizeError}
- />
- onChange({ errorLogsMaxFiles: e.target.value })}
- disabled={disabled}
- error={errorLogsMaxFilesError}
- />
- onChange({ redisUsageQueueRetentionSeconds: e.target.value })}
- disabled={disabled}
- hint={t('config_management.visual.sections.system.redis_usage_retention_hint')}
- error={redisUsageQueueRetentionError}
- />
+
+ onChange({ logsMaxTotalSizeMb: e.target.value })}
+ disabled={disabled}
+ error={logsMaxSizeError}
+ />
+
+
+ onChange({ errorLogsMaxFiles: e.target.value })}
+ disabled={disabled}
+ error={errorLogsMaxFilesError}
+ />
+
+
+
+ onChange({ redisUsageQueueRetentionSeconds: e.target.value })
+ }
+ disabled={disabled}
+ hint={t(
+ 'config_management.visual.sections.system.redis_usage_retention_hint'
+ )}
+ error={redisUsageQueueRetentionError}
+ />
+
- onChange({ usageStatisticsEnabled })}
- />
+
+ onChange({ usageStatisticsEnabled })}
+ />
+
@@ -1039,12 +1281,14 @@ export function VisualConfigEditor({
{quotaSwitchProjectToggle}
{quotaSwitchPreviewModelToggle}
- onChange({ quotaAntigravityCredits })}
- />
+
+ onChange({ quotaAntigravityCredits })}
+ />
+
@@ -1060,91 +1304,99 @@ export function VisualConfigEditor({
>
-
-
-
- onChange({
- streaming: {
- ...values.streaming,
- keepaliveSeconds: e.target.value,
- },
- })
- }
- disabled={disabled}
- />
- {isKeepaliveDisabled ? (
-
- {t('config_management.visual.sections.streaming.disabled')}
-
- ) : null}
-
-
+
+
+
+
+ onChange({
+ streaming: {
+ ...values.streaming,
+ keepaliveSeconds: e.target.value,
+ },
+ })
+ }
+ disabled={disabled}
+ />
+ {isKeepaliveDisabled ? (
+
+ {t('config_management.visual.sections.streaming.disabled')}
+
+ ) : null}
+
+
+
-
- onChange({
- streaming: {
- ...values.streaming,
- bootstrapRetries: e.target.value,
- },
- })
- }
- disabled={disabled}
- hint={t('config_management.visual.sections.streaming.bootstrap_hint')}
- error={bootstrapRetriesError}
- />
+
+
+ onChange({
+ streaming: {
+ ...values.streaming,
+ bootstrapRetries: e.target.value,
+ },
+ })
+ }
+ disabled={disabled}
+ hint={t('config_management.visual.sections.streaming.bootstrap_hint')}
+ error={bootstrapRetriesError}
+ />
+
-
-
-
- onChange({
- streaming: {
- ...values.streaming,
- nonstreamKeepaliveInterval: e.target.value,
- },
- })
- }
- disabled={disabled}
- />
- {isNonstreamKeepaliveDisabled ? (
-
- {t('config_management.visual.sections.streaming.disabled')}
-
- ) : null}
-
-
+
+
+
+
+ onChange({
+ streaming: {
+ ...values.streaming,
+ nonstreamKeepaliveInterval: e.target.value,
+ },
+ })
+ }
+ disabled={disabled}
+ />
+ {isNonstreamKeepaliveDisabled ? (
+
+ {t('config_management.visual.sections.streaming.disabled')}
+
+ ) : null}
+
+
+
@@ -1166,43 +1418,51 @@ export function VisualConfigEditor({
>
- onChange({ pluginsEnabled })}
- />
+
+ onChange({ pluginsEnabled })}
+ />
+
-
-
-
- {t('config_management.visual.sections.system.plugin_store_sources_label')}
-
-
-
- {t('config_management.visual.sections.system.plugin_store_sources_hint')}
+
+
+
+
+ {t(
+ 'config_management.visual.sections.system.plugin_store_sources_label'
+ )}
+
+
+
+ {t(
+ 'config_management.visual.sections.system.plugin_store_sources_hint'
+ )}
+
-
-
+
+
@@ -1211,32 +1471,36 @@ export function VisualConfigEditor({
defaultOpen={false}
>
-
- onChange({ antigravitySignatureCacheEnabled })
- }
- />
-
- onChange({ antigravitySignatureBypassStrict })
- }
- />
+
+
+ onChange({ antigravitySignatureCacheEnabled })
+ }
+ />
+
+
+
+ onChange({ antigravitySignatureBypassStrict })
+ }
+ />
+
@@ -1252,61 +1516,75 @@ export function VisualConfigEditor({
- onChange({ claudeHeaderUserAgent: e.target.value })}
- disabled={disabled}
- />
- onChange({ claudeHeaderPackageVersion: e.target.value })}
- disabled={disabled}
- />
- onChange({ claudeHeaderRuntimeVersion: e.target.value })}
- disabled={disabled}
- />
- onChange({ claudeHeaderOs: e.target.value })}
- disabled={disabled}
- />
- onChange({ claudeHeaderArch: e.target.value })}
- disabled={disabled}
- />
- onChange({ claudeHeaderTimeout: e.target.value })}
- disabled={disabled}
- />
+
+ onChange({ claudeHeaderUserAgent: e.target.value })}
+ disabled={disabled}
+ />
+
+
+ onChange({ claudeHeaderPackageVersion: e.target.value })}
+ disabled={disabled}
+ />
+
+
+ onChange({ claudeHeaderRuntimeVersion: e.target.value })}
+ disabled={disabled}
+ />
+
+
+ onChange({ claudeHeaderOs: e.target.value })}
+ disabled={disabled}
+ />
+
+
+ onChange({ claudeHeaderArch: e.target.value })}
+ disabled={disabled}
+ />
+
+
+ onChange({ claudeHeaderTimeout: e.target.value })}
+ disabled={disabled}
+ />
+
-
- onChange({ claudeHeaderStabilizeDeviceProfile })
- }
- />
+
+
+ onChange({ claudeHeaderStabilizeDeviceProfile })
+ }
+ />
+
@@ -1315,33 +1593,39 @@ export function VisualConfigEditor({
- onChange({ codexHeaderUserAgent: e.target.value })}
- disabled={disabled}
- />
- onChange({ codexHeaderBetaFeatures: e.target.value })}
- disabled={disabled}
- />
+
+ onChange({ codexHeaderUserAgent: e.target.value })}
+ disabled={disabled}
+ />
+
+
+ onChange({ codexHeaderBetaFeatures: e.target.value })}
+ disabled={disabled}
+ />
+
- onChange({ codexIdentityConfuse })}
- />
+
+ onChange({ codexIdentityConfuse })}
+ />
+
@@ -1359,69 +1643,79 @@ export function VisualConfigEditor({
description={t('config_management.visual.sections.payload.description')}
>
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
diff --git a/src/components/config/configSearchIndex.ts b/src/components/config/configSearchIndex.ts
new file mode 100644
index 00000000..2536eb3c
--- /dev/null
+++ b/src/components/config/configSearchIndex.ts
@@ -0,0 +1,499 @@
+// Search index for the visual config editor's global "jump to field" search.
+//
+// IMPORTANT: this index is maintained by hand and is NOT what drives field
+// rendering — it only powers search. When you add, remove, or move a field in
+// VisualConfigEditor.tsx, update the matching entry here (and wrap the field's
+// JSX in using the same `fieldId`).
+
+export type VisualSectionId =
+ | 'connectivity'
+ | 'network'
+ | 'logging'
+ | 'quota'
+ | 'streaming'
+ | 'advanced'
+ | 'payload';
+
+export interface ConfigFieldSearchEntry {
+ /** Stable anchor id; matches FieldAnchor's `fieldId` and the rendered DOM id. */
+ fieldId: string;
+ sectionId: VisualSectionId;
+ /** i18n key resolved with t() at search time so matching follows the active language. */
+ labelKey: string;
+ /** Optional secondary i18n key shown next to the label to disambiguate duplicates
+ * (e.g. Claude vs Codex "User-Agent"). Also searchable. */
+ qualifierKey?: string;
+ /** Optional hint i18n key — searchable but not shown in results. */
+ hintKey?: string;
+ /** Backend YAML key aliases, e.g. ['proxy-url']. Static strings (language-agnostic). */
+ yamlKeys?: string[];
+ /** Extra synonyms to match against (language-agnostic, lowercase). */
+ keywords?: string[];
+}
+
+/** DOM id for a field anchor — kept in one place so the index and the anchors agree. */
+export const configFieldDomId = (fieldId: string) => `cfg-field-${fieldId}`;
+
+type Translate = (key: string) => string;
+
+// Compact helper: every label/hint key lives under config_management.visual.
+const L = (key: string) => `config_management.visual.${key}`;
+
+export const CONFIG_FIELD_SEARCH_INDEX: ConfigFieldSearchEntry[] = [
+ // ── connectivity ──────────────────────────────────────────────────────────
+ {
+ fieldId: 'host',
+ sectionId: 'connectivity',
+ labelKey: L('sections.server.host'),
+ yamlKeys: ['host'],
+ },
+ {
+ fieldId: 'port',
+ sectionId: 'connectivity',
+ labelKey: L('sections.server.port'),
+ yamlKeys: ['port'],
+ },
+ {
+ fieldId: 'authDir',
+ sectionId: 'connectivity',
+ labelKey: L('sections.auth.auth_dir'),
+ hintKey: L('sections.auth.auth_dir_hint'),
+ yamlKeys: ['auth-dir'],
+ },
+ {
+ fieldId: 'apiKeys',
+ sectionId: 'connectivity',
+ labelKey: L('api_keys.label'),
+ yamlKeys: ['api-keys'],
+ keywords: ['api key', 'apikey', 'token'],
+ },
+ {
+ fieldId: 'tlsEnable',
+ sectionId: 'connectivity',
+ labelKey: L('sections.tls.enable'),
+ hintKey: L('sections.tls.enable_desc'),
+ yamlKeys: ['tls'],
+ keywords: ['tls', 'ssl', 'https'],
+ },
+ {
+ fieldId: 'tlsCert',
+ sectionId: 'connectivity',
+ labelKey: L('sections.tls.cert'),
+ yamlKeys: ['tls', 'cert'],
+ keywords: ['tls', 'ssl', 'certificate'],
+ },
+ {
+ fieldId: 'tlsKey',
+ sectionId: 'connectivity',
+ labelKey: L('sections.tls.key'),
+ yamlKeys: ['tls', 'key'],
+ keywords: ['tls', 'ssl', 'private key'],
+ },
+ {
+ fieldId: 'rmAllowRemote',
+ sectionId: 'connectivity',
+ labelKey: L('sections.remote.allow_remote'),
+ hintKey: L('sections.remote.allow_remote_desc'),
+ yamlKeys: ['remote-management', 'allow-remote'],
+ },
+ {
+ fieldId: 'rmDisableControlPanel',
+ sectionId: 'connectivity',
+ labelKey: L('sections.remote.disable_panel'),
+ yamlKeys: ['remote-management', 'disable-control-panel'],
+ },
+ {
+ fieldId: 'rmDisableAutoUpdatePanel',
+ sectionId: 'connectivity',
+ labelKey: L('sections.remote.disable_auto_update_panel'),
+ yamlKeys: ['remote-management', 'disable-auto-update-panel'],
+ },
+ {
+ fieldId: 'rmSecretKey',
+ sectionId: 'connectivity',
+ labelKey: L('sections.remote.secret_key'),
+ yamlKeys: ['remote-management', 'secret-key'],
+ },
+ {
+ fieldId: 'rmPanelRepo',
+ sectionId: 'connectivity',
+ labelKey: L('sections.remote.panel_repo'),
+ yamlKeys: ['remote-management', 'panel-github-repository'],
+ },
+ // ── network ───────────────────────────────────────────────────────────────
+ {
+ fieldId: 'proxyUrl',
+ sectionId: 'network',
+ labelKey: L('sections.network.proxy_url'),
+ yamlKeys: ['proxy-url'],
+ },
+ {
+ fieldId: 'requestRetry',
+ sectionId: 'network',
+ labelKey: L('sections.network.request_retry'),
+ yamlKeys: ['request-retry'],
+ },
+ {
+ fieldId: 'maxRetryCredentials',
+ sectionId: 'network',
+ labelKey: L('sections.network.max_retry_credentials'),
+ hintKey: L('sections.network.max_retry_credentials_hint'),
+ yamlKeys: ['max-retry-credentials'],
+ },
+ {
+ fieldId: 'maxRetryInterval',
+ sectionId: 'network',
+ labelKey: L('sections.network.max_retry_interval'),
+ yamlKeys: ['max-retry-interval'],
+ },
+ {
+ fieldId: 'authAutoRefreshWorkers',
+ sectionId: 'network',
+ labelKey: L('sections.network.auth_auto_refresh_workers'),
+ hintKey: L('sections.network.auth_auto_refresh_workers_hint'),
+ yamlKeys: ['auth-auto-refresh-workers'],
+ },
+ {
+ fieldId: 'routingStrategy',
+ sectionId: 'network',
+ labelKey: L('sections.network.routing_strategy'),
+ hintKey: L('sections.network.routing_strategy_hint'),
+ yamlKeys: ['routing', 'strategy'],
+ keywords: ['round-robin', 'fill-first'],
+ },
+ {
+ fieldId: 'disableImageGeneration',
+ sectionId: 'network',
+ labelKey: L('sections.network.disable_image_generation'),
+ hintKey: L('sections.network.disable_image_generation_hint'),
+ yamlKeys: ['disable-image-generation'],
+ },
+ {
+ fieldId: 'gptImage2BaseModel',
+ sectionId: 'network',
+ labelKey: L('sections.network.gpt_image_2_base_model'),
+ hintKey: L('sections.network.gpt_image_2_base_model_hint'),
+ yamlKeys: ['gpt-image-2-base-model'],
+ },
+ {
+ fieldId: 'routingSessionAffinityTTL',
+ sectionId: 'network',
+ labelKey: L('sections.network.session_affinity_ttl'),
+ yamlKeys: ['routing', 'session-affinity-ttl'],
+ },
+ {
+ fieldId: 'forceModelPrefix',
+ sectionId: 'network',
+ labelKey: L('sections.network.force_model_prefix'),
+ hintKey: L('sections.network.force_model_prefix_desc'),
+ yamlKeys: ['force-model-prefix'],
+ },
+ {
+ fieldId: 'passthroughHeaders',
+ sectionId: 'network',
+ labelKey: L('sections.network.passthrough_headers'),
+ hintKey: L('sections.network.passthrough_headers_desc'),
+ yamlKeys: ['passthrough-headers'],
+ },
+ {
+ fieldId: 'disableCooling',
+ sectionId: 'network',
+ labelKey: L('sections.network.disable_cooling'),
+ hintKey: L('sections.network.disable_cooling_desc'),
+ yamlKeys: ['disable-cooling'],
+ },
+ {
+ fieldId: 'routingSessionAffinity',
+ sectionId: 'network',
+ labelKey: L('sections.network.session_affinity'),
+ yamlKeys: ['routing', 'session-affinity'],
+ },
+ {
+ fieldId: 'wsAuth',
+ sectionId: 'network',
+ labelKey: L('sections.network.ws_auth'),
+ hintKey: L('sections.network.ws_auth_desc'),
+ yamlKeys: ['ws-auth'],
+ keywords: ['websocket'],
+ },
+ {
+ fieldId: 'enableGeminiCliEndpoint',
+ sectionId: 'network',
+ labelKey: L('sections.network.enable_gemini_cli_endpoint'),
+ hintKey: L('sections.network.enable_gemini_cli_endpoint_desc'),
+ yamlKeys: ['enable-gemini-cli-endpoint'],
+ },
+ // ── logging ───────────────────────────────────────────────────────────────
+ {
+ fieldId: 'debug',
+ sectionId: 'logging',
+ labelKey: L('sections.system.debug'),
+ hintKey: L('sections.system.debug_desc'),
+ yamlKeys: ['debug'],
+ },
+ {
+ fieldId: 'commercialMode',
+ sectionId: 'logging',
+ labelKey: L('sections.system.commercial_mode'),
+ hintKey: L('sections.system.commercial_mode_desc'),
+ yamlKeys: ['commercial-mode'],
+ },
+ {
+ fieldId: 'loggingToFile',
+ sectionId: 'logging',
+ labelKey: L('sections.system.logging_to_file'),
+ hintKey: L('sections.system.logging_to_file_desc'),
+ yamlKeys: ['logging-to-file'],
+ },
+ {
+ fieldId: 'logsMaxTotalSizeMb',
+ sectionId: 'logging',
+ labelKey: L('sections.system.logs_max_size'),
+ yamlKeys: ['logs-max-total-size-mb'],
+ },
+ {
+ fieldId: 'errorLogsMaxFiles',
+ sectionId: 'logging',
+ labelKey: L('sections.system.error_logs_max_files'),
+ yamlKeys: ['error-logs-max-files'],
+ },
+ {
+ fieldId: 'redisUsageQueueRetentionSeconds',
+ sectionId: 'logging',
+ labelKey: L('sections.system.redis_usage_retention'),
+ hintKey: L('sections.system.redis_usage_retention_hint'),
+ yamlKeys: ['redis-usage-queue-retention-seconds'],
+ },
+ {
+ fieldId: 'usageStatisticsEnabled',
+ sectionId: 'logging',
+ labelKey: L('sections.system.usage_statistics_enabled'),
+ hintKey: L('sections.system.usage_statistics_enabled_desc'),
+ yamlKeys: ['usage-statistics-enabled'],
+ },
+ // ── quota ─────────────────────────────────────────────────────────────────
+ {
+ fieldId: 'quotaSwitchProject',
+ sectionId: 'quota',
+ labelKey: L('sections.quota.switch_project'),
+ hintKey: L('sections.quota.switch_project_desc'),
+ yamlKeys: ['quota-exceeded', 'switch-project'],
+ },
+ {
+ fieldId: 'quotaSwitchPreviewModel',
+ sectionId: 'quota',
+ labelKey: L('sections.quota.switch_preview_model'),
+ hintKey: L('sections.quota.switch_preview_model_desc'),
+ yamlKeys: ['quota-exceeded', 'switch-preview-model'],
+ },
+ {
+ fieldId: 'quotaAntigravityCredits',
+ sectionId: 'quota',
+ labelKey: L('sections.quota.antigravity_credits'),
+ yamlKeys: ['quota-exceeded', 'antigravity-credits'],
+ },
+ // ── streaming ─────────────────────────────────────────────────────────────
+ {
+ fieldId: 'streamingKeepaliveSeconds',
+ sectionId: 'streaming',
+ labelKey: L('sections.streaming.keepalive_seconds'),
+ hintKey: L('sections.streaming.keepalive_hint'),
+ yamlKeys: ['streaming', 'keepalive-seconds'],
+ },
+ {
+ fieldId: 'streamingBootstrapRetries',
+ sectionId: 'streaming',
+ labelKey: L('sections.streaming.bootstrap_retries'),
+ hintKey: L('sections.streaming.bootstrap_hint'),
+ yamlKeys: ['streaming', 'bootstrap-retries'],
+ },
+ {
+ fieldId: 'streamingNonstreamKeepalive',
+ sectionId: 'streaming',
+ labelKey: L('sections.streaming.nonstream_keepalive'),
+ hintKey: L('sections.streaming.nonstream_keepalive_hint'),
+ yamlKeys: ['streaming', 'nonstream-keepalive-interval'],
+ },
+ // ── advanced ──────────────────────────────────────────────────────────────
+ {
+ fieldId: 'pluginsEnabled',
+ sectionId: 'advanced',
+ labelKey: L('sections.system.plugins_enabled'),
+ hintKey: L('sections.system.plugins_enabled_desc'),
+ yamlKeys: ['plugins'],
+ },
+ {
+ fieldId: 'pluginStoreSources',
+ sectionId: 'advanced',
+ labelKey: L('sections.system.plugin_store_sources'),
+ hintKey: L('sections.system.plugin_store_sources_hint'),
+ yamlKeys: ['plugins', 'store-sources'],
+ },
+ {
+ fieldId: 'antigravitySignatureCacheEnabled',
+ sectionId: 'advanced',
+ labelKey: L('sections.system.antigravity_signature_cache'),
+ hintKey: L('sections.system.antigravity_signature_cache_desc'),
+ yamlKeys: ['antigravity-signature-cache-enabled'],
+ },
+ {
+ fieldId: 'antigravitySignatureBypassStrict',
+ sectionId: 'advanced',
+ labelKey: L('sections.system.antigravity_signature_strict'),
+ hintKey: L('sections.system.antigravity_signature_strict_desc'),
+ yamlKeys: ['antigravity-signature-bypass-strict'],
+ },
+ // Claude header defaults — qualifierKey disambiguates the shared "User-Agent" label.
+ {
+ fieldId: 'claudeHeaderUserAgent',
+ sectionId: 'advanced',
+ labelKey: L('sections.headers.user_agent'),
+ qualifierKey: L('sections.headers.claude_title'),
+ yamlKeys: ['claude-header-defaults', 'user-agent'],
+ keywords: ['claude'],
+ },
+ {
+ fieldId: 'claudeHeaderPackageVersion',
+ sectionId: 'advanced',
+ labelKey: L('sections.headers.package_version'),
+ qualifierKey: L('sections.headers.claude_title'),
+ yamlKeys: ['claude-header-defaults', 'package-version'],
+ keywords: ['claude'],
+ },
+ {
+ fieldId: 'claudeHeaderRuntimeVersion',
+ sectionId: 'advanced',
+ labelKey: L('sections.headers.runtime_version'),
+ qualifierKey: L('sections.headers.claude_title'),
+ yamlKeys: ['claude-header-defaults', 'runtime-version'],
+ keywords: ['claude'],
+ },
+ {
+ fieldId: 'claudeHeaderOs',
+ sectionId: 'advanced',
+ labelKey: L('sections.headers.os'),
+ qualifierKey: L('sections.headers.claude_title'),
+ yamlKeys: ['claude-header-defaults', 'os'],
+ keywords: ['claude'],
+ },
+ {
+ fieldId: 'claudeHeaderArch',
+ sectionId: 'advanced',
+ labelKey: L('sections.headers.arch'),
+ qualifierKey: L('sections.headers.claude_title'),
+ yamlKeys: ['claude-header-defaults', 'arch'],
+ keywords: ['claude'],
+ },
+ {
+ fieldId: 'claudeHeaderTimeout',
+ sectionId: 'advanced',
+ labelKey: L('sections.headers.timeout'),
+ qualifierKey: L('sections.headers.claude_title'),
+ yamlKeys: ['claude-header-defaults', 'timeout'],
+ keywords: ['claude'],
+ },
+ {
+ fieldId: 'claudeHeaderStabilizeDeviceProfile',
+ sectionId: 'advanced',
+ labelKey: L('sections.headers.stabilize_device'),
+ qualifierKey: L('sections.headers.claude_title'),
+ hintKey: L('sections.headers.stabilize_device_desc'),
+ yamlKeys: ['claude-header-defaults', 'stabilize-device-profile'],
+ keywords: ['claude'],
+ },
+ // Codex header defaults.
+ {
+ fieldId: 'codexHeaderUserAgent',
+ sectionId: 'advanced',
+ labelKey: L('sections.headers.user_agent'),
+ qualifierKey: L('sections.headers.codex_title'),
+ yamlKeys: ['codex-header-defaults', 'user-agent'],
+ keywords: ['codex'],
+ },
+ {
+ fieldId: 'codexHeaderBetaFeatures',
+ sectionId: 'advanced',
+ labelKey: L('sections.headers.beta_features'),
+ qualifierKey: L('sections.headers.codex_title'),
+ yamlKeys: ['codex-header-defaults', 'beta-features'],
+ keywords: ['codex'],
+ },
+ {
+ fieldId: 'codexIdentityConfuse',
+ sectionId: 'advanced',
+ labelKey: L('sections.headers.codex_identity_confuse'),
+ qualifierKey: L('sections.headers.codex_title'),
+ hintKey: L('sections.headers.codex_identity_confuse_desc'),
+ yamlKeys: ['codex-header-defaults', 'identity-confuse'],
+ keywords: ['codex'],
+ },
+ // ── payload (coarse: one entry per rule group) ──────────────────────────────
+ {
+ fieldId: 'payloadDefaultRules',
+ sectionId: 'payload',
+ labelKey: L('sections.payload.default_rules'),
+ hintKey: L('sections.payload.default_rules_desc'),
+ keywords: ['payload', 'rule'],
+ },
+ {
+ fieldId: 'payloadDefaultRawRules',
+ sectionId: 'payload',
+ labelKey: L('sections.payload.default_raw_rules'),
+ hintKey: L('sections.payload.default_raw_rules_desc'),
+ keywords: ['payload', 'rule', 'json'],
+ },
+ {
+ fieldId: 'payloadOverrideRules',
+ sectionId: 'payload',
+ labelKey: L('sections.payload.override_rules'),
+ hintKey: L('sections.payload.override_rules_desc'),
+ keywords: ['payload', 'rule'],
+ },
+ {
+ fieldId: 'payloadOverrideRawRules',
+ sectionId: 'payload',
+ labelKey: L('sections.payload.override_raw_rules'),
+ hintKey: L('sections.payload.override_raw_rules_desc'),
+ keywords: ['payload', 'rule', 'json'],
+ },
+ {
+ fieldId: 'payloadFilterRules',
+ sectionId: 'payload',
+ labelKey: L('sections.payload.filter_rules'),
+ hintKey: L('sections.payload.filter_rules_desc'),
+ keywords: ['payload', 'rule', 'filter'],
+ },
+];
+
+const MAX_RESULTS = 8;
+
+/**
+ * Lowercase substring search over label + qualifier + hint + YAML keys + keywords.
+ * Returns the best ~8 matches, label/qualifier hits ranked above alias-only hits.
+ */
+export function searchConfigFields(query: string, t: Translate): ConfigFieldSearchEntry[] {
+ const q = query.trim().toLowerCase();
+ if (!q) return [];
+
+ const scored: { entry: ConfigFieldSearchEntry; score: number }[] = [];
+
+ for (const entry of CONFIG_FIELD_SEARCH_INDEX) {
+ const label = t(entry.labelKey).toLowerCase();
+ const qualifier = entry.qualifierKey ? t(entry.qualifierKey).toLowerCase() : '';
+ const hint = entry.hintKey ? t(entry.hintKey).toLowerCase() : '';
+ const yaml = (entry.yamlKeys ?? []).join(' ').toLowerCase();
+ const keywords = (entry.keywords ?? []).join(' ').toLowerCase();
+
+ let score = Number.POSITIVE_INFINITY;
+ if (label.startsWith(q)) score = 0;
+ else if (label.includes(q)) score = 1;
+ else if (qualifier.includes(q) || keywords.includes(q)) score = 2;
+ else if (yaml.includes(q)) score = 3;
+ else if (hint.includes(q)) score = 4;
+
+ if (Number.isFinite(score)) scored.push({ entry, score });
+ }
+
+ scored.sort((a, b) => a.score - b.score);
+ return scored.slice(0, MAX_RESULTS).map((item) => item.entry);
+}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index cdf2ae14..f9ede766 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -917,6 +917,10 @@
"validation_banner": "Some advanced settings have validation errors and are blocking save. Switch to full mode to fix them.",
"switch_to_full": "Switch to full mode"
},
+ "search": {
+ "placeholder": "Search settings (label or YAML key)",
+ "no_results": "No matching settings"
+ },
"sections": {
"connectivity": {
"title": "Access & Authentication",
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index d691bc3d..86cd97c8 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -904,6 +904,10 @@
"validation_banner": "В некоторых дополнительных параметрах есть ошибки проверки, сохранение заблокировано. Переключитесь в полный режим, чтобы исправить их.",
"switch_to_full": "Перейти в полный режим"
},
+ "search": {
+ "placeholder": "Поиск настроек (по названию или ключу YAML)",
+ "no_results": "Нет подходящих настроек"
+ },
"sections": {
"connectivity": {
"title": "Доступ и аутентификация",
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index 9cafe6d1..0bc00c59 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -917,6 +917,10 @@
"validation_banner": "部分高级配置项存在校验错误,已阻止保存。请切换到完整模式进行修复。",
"switch_to_full": "切换完整模式"
},
+ "search": {
+ "placeholder": "搜索配置项(标签或 YAML 键名)",
+ "no_results": "没有匹配的配置项"
+ },
"sections": {
"connectivity": {
"title": "接入与认证",
diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json
index aea09558..8606e90a 100644
--- a/src/i18n/locales/zh-TW.json
+++ b/src/i18n/locales/zh-TW.json
@@ -943,6 +943,10 @@
"validation_banner": "部分進階設定項存在驗證錯誤,已阻止儲存。請切換到完整模式進行修正。",
"switch_to_full": "切換完整模式"
},
+ "search": {
+ "placeholder": "搜尋設定項(標籤或 YAML 鍵名)",
+ "no_results": "沒有符合的設定項"
+ },
"sections": {
"connectivity": {
"title": "接入與認證",