From aa114b26432559490b5ab015306d8838cff6f9f4 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Fri, 19 Jun 2026 01:38:28 +0800 Subject: [PATCH] feat: add search index for visual config editor and update i18n translations - Introduced a new search index for the visual config editor to facilitate "jump to field" functionality. - Added a `searchConfigFields` function to perform searches over labels, hints, YAML keys, and keywords. - Updated English, Russian, Simplified Chinese, and Traditional Chinese translations to include search-related strings. --- .../config/VisualConfigEditor.module.scss | 110 ++ src/components/config/VisualConfigEditor.tsx | 1554 ++++++++++------- src/components/config/configSearchIndex.ts | 499 ++++++ src/i18n/locales/en.json | 4 + src/i18n/locales/ru.json | 4 + src/i18n/locales/zh-CN.json | 4 + src/i18n/locales/zh-TW.json | 4 + 7 files changed, 1549 insertions(+), 630 deletions(-) create mode 100644 src/components/config/configSearchIndex.ts 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) => ( + + )) + ) : ( +
+ {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({ + 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_hint')} + + +
+ + +
+ {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": "接入與認證",