diff --git a/src/components/config/VisualConfigEditor.module.scss b/src/components/config/VisualConfigEditor.module.scss
index a9671297..b0a7c45e 100644
--- a/src/components/config/VisualConfigEditor.module.scss
+++ b/src/components/config/VisualConfigEditor.module.scss
@@ -140,6 +140,9 @@
.overviewHeader {
display: flex;
align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 10px;
min-width: 0;
@include mobile {
@@ -172,6 +175,240 @@
background: var(--warning-bg);
}
+.modeSwitch {
+ display: inline-flex;
+ gap: 4px;
+ padding: 3px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: var(--bg-secondary);
+}
+
+.modeButton {
+ @include button-reset;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 30px;
+ padding: 0 12px;
+ border-radius: 6px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ font-weight: 700;
+ line-height: 1.2;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease;
+
+ &:hover {
+ color: var(--text-primary);
+ }
+}
+
+.modeButtonActive {
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ 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);
+ }
+}
+
+// Keyboard/pointer "active" option — the aria-activedescendant target.
+.searchResultItemActive {
+ 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;
+ gap: 16px;
+ width: 100%;
+ max-width: 760px;
+ min-width: 0;
+}
+
+.simpleForm {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ min-width: 0;
+}
+
+// Wrap bare inputs so they read as cards consistent with toggleRow / API key blocks.
+.simpleField {
+ padding: 14px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: transparent;
+}
+
+.simpleBanner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ padding: 12px 14px;
+ border: 1px solid var(--warning-border);
+ border-radius: 8px;
+ background: var(--warning-bg);
+ color: var(--warning-text);
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 1.5;
+}
+
+.simpleBannerAction {
+ @include button-reset;
+ flex: 0 0 auto;
+ padding: 6px 12px;
+ border: 1px solid var(--warning-border);
+ border-radius: 6px;
+ color: var(--warning-text);
+ font-size: 12px;
+ font-weight: 700;
+ transition: background-color 0.15s ease;
+
+ &:hover {
+ background: color-mix(in srgb, var(--warning-text) 10%, transparent);
+ }
+}
+
+.simpleMore {
+ @include button-reset;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+ min-height: 46px;
+ padding: 12px 16px;
+ border: 1px dashed var(--border-color);
+ border-radius: 8px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ font-weight: 700;
+ text-align: center;
+ transition:
+ background-color 0.15s ease,
+ border-color 0.15s ease,
+ color 0.15s ease;
+
+ &:hover {
+ border-color: var(--text-primary);
+ color: var(--text-primary);
+ background: color-mix(in srgb, var(--text-primary) 4%, transparent);
+ }
+}
+
.workspace {
display: flex;
flex-direction: column;
diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx
index 9c8b694a..8b90ee43 100644
--- a/src/components/config/VisualConfigEditor.tsx
+++ b/src/components/config/VisualConfigEditor.tsx
@@ -10,15 +10,19 @@ import {
} from 'react';
import { useTranslation } from 'react-i18next';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
+import { Collapsible } from '@/components/ui/Collapsible';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import {
IconCode,
- IconDiamond,
IconKey,
+ IconNetwork,
IconSatellite,
- IconSettings,
+ IconScrollText,
+ IconSearch,
+ IconShield,
+ IconSlidersHorizontal,
IconTimer,
type IconProps,
} from '@/components/ui/icons';
@@ -39,9 +43,17 @@ import {
PayloadRulesEditor,
StringListEditor,
} from './VisualConfigEditorBlocks';
+import {
+ configFieldDomId,
+ searchConfigFields,
+ type ConfigFieldSearchEntry,
+ type VisualSectionId,
+} from './configSearchIndex';
import styles from './VisualConfigEditor.module.scss';
-type VisualSectionId = 'server' | 'auth' | 'system' | 'quota' | 'streaming' | 'payload';
+type EditorMode = 'simple' | 'full';
+
+const EDITOR_MODE_STORAGE_KEY = 'config-management:editor-mode';
type VisualSection = {
id: VisualSectionId;
@@ -98,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,
@@ -178,12 +200,140 @@ export function VisualConfigEditor({
const nonstreamKeepaliveInputId = useId();
const nonstreamKeepaliveHintId = `${nonstreamKeepaliveInputId}-hint`;
const nonstreamKeepaliveErrorId = `${nonstreamKeepaliveInputId}-error`;
- const [activeSectionId, setActiveSectionId] = useState('server');
+ const [mode, setMode] = useState(() => {
+ const saved = localStorage.getItem(EDITOR_MODE_STORAGE_KEY);
+ return saved === 'full' ? 'full' : 'simple';
+ });
+ const [activeSectionId, setActiveSectionId] = useState('connectivity');
const sectionRefs = useRef>>({});
const mobileNavScrollerRef = useRef(null);
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);
+ // Highlighted option for keyboard navigation of the results listbox (-1 = none).
+ const [activeResultIndex, setActiveResultIndex] = useState(0);
+ const searchListboxId = useId();
+ const searchResultsRef = useRef(null);
+ // 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]);
+ // The results popup is visible only when the box is open AND there's a (trimmed) query.
+ const isResultsOpen = searchOpen && Boolean(searchQuery.trim());
+ // Clamp the highlighted index to the current result set so a stale index (e.g. after the
+ // query narrows the list) never points past the end or at an option that no longer exists.
+ const effectiveActiveIndex =
+ searchResults.length > 0
+ ? Math.min(Math.max(activeResultIndex, 0), searchResults.length - 1)
+ : -1;
+
+ 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 targetFieldId =
+ (fieldId === 'tlsCert' || fieldId === 'tlsKey') && !values.tlsEnable
+ ? 'tlsEnable'
+ : fieldId;
+
+ const el = document.getElementById(configFieldDomId(targetFieldId));
+ if (!el) {
+ // Field not rendered right now (e.g. TLS cert while TLS is disabled) — fall back to
+ // bringing its section into view horizontally.
+ sectionRefs.current[sectionId]?.scrollIntoView({ block: 'nearest', inline: '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);
+ }
+
+ // Full-mode sections live in a horizontal scroll-snap container (`scroll-snap-type: x
+ // mandatory`). A single field-level scrollIntoView() tries to do the horizontal section
+ // switch AND the vertical field scroll at once, which the snap pulls back / lands wrong.
+ // So: (1) switch to the target section horizontally and instantly (no smooth → no snap
+ // fight), then (2) next frame, scroll the field vertically with inline:'nearest' so it
+ // can't re-trigger horizontal snapping.
+ sectionRefs.current[sectionId]?.scrollIntoView({ block: 'nearest', inline: 'start' });
+ requestAnimationFrame(() => {
+ el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
+ 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, values.tlsEnable]);
+
+ // 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]);
+
+ // Keep the highlighted option scrolled into view during keyboard navigation.
+ useEffect(() => {
+ if (!isResultsOpen || effectiveActiveIndex < 0) return;
+ const node = searchResultsRef.current?.querySelector(
+ `[data-result-index="${effectiveActiveIndex}"]`
+ );
+ node?.scrollIntoView({ block: 'nearest' });
+ }, [effectiveActiveIndex, isResultsOpen]);
const isKeepaliveDisabled =
values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0';
@@ -270,31 +420,32 @@ export function VisualConfigEditor({
const sections = useMemo(
() => [
{
- id: 'server',
- title: t('config_management.visual.sections.server.title'),
- icon: IconSettings,
+ id: 'connectivity',
+ title: t('config_management.visual.sections.connectivity.title'),
+ icon: IconKey,
errorCount: countErrors(['port']),
},
{
- id: 'auth',
- title: t('config_management.visual.sections.auth.title'),
- icon: IconKey,
- errorCount: 0,
- },
- {
- id: 'system',
- title: t('config_management.visual.sections.system.title'),
- icon: IconDiamond,
+ id: 'network',
+ title: t('config_management.visual.sections.network.title'),
+ icon: IconNetwork,
errorCount: countErrors([
- 'errorLogsMaxFiles',
- 'logsMaxTotalSizeMb',
- 'redisUsageQueueRetentionSeconds',
'requestRetry',
'maxRetryCredentials',
'maxRetryInterval',
'authAutoRefreshWorkers',
]),
},
+ {
+ id: 'logging',
+ title: t('config_management.visual.sections.logging.title'),
+ icon: IconScrollText,
+ errorCount: countErrors([
+ 'errorLogsMaxFiles',
+ 'logsMaxTotalSizeMb',
+ 'redisUsageQueueRetentionSeconds',
+ ]),
+ },
{
id: 'quota',
title: t('config_management.visual.sections.quota.title'),
@@ -311,6 +462,12 @@ export function VisualConfigEditor({
'streaming.nonstreamKeepaliveInterval',
]),
},
+ {
+ id: 'advanced',
+ title: t('config_management.visual.sections.advanced.title'),
+ icon: IconShield,
+ errorCount: 0,
+ },
{
id: 'payload',
title: t('config_management.visual.sections.payload.title'),
@@ -323,9 +480,16 @@ export function VisualConfigEditor({
const hasValidationIssues =
sections.some((section) => section.errorCount > 0) || hasPayloadValidationErrors;
+ // Validation errors that live in fields not surfaced by simple mode (everything except `port`).
+ const hasHiddenValidationIssues =
+ (Object.keys(validationErrors ?? {}) as VisualConfigFieldPath[]).some(
+ (field) => field !== 'port' && Boolean(validationErrors?.[field])
+ ) || hasPayloadValidationErrors;
+ const payloadValidationKey = hasPayloadValidationErrors ? 'payload-errors' : 'payload-ok';
const activeSection = sections.find((section) => section.id === activeSectionId) ?? sections[0];
useEffect(() => {
+ if (mode !== 'full') return undefined;
if (!isCurrentLayer) return undefined;
if (typeof IntersectionObserver === 'undefined') return undefined;
@@ -350,10 +514,10 @@ export function VisualConfigEditor({
}
return () => observer.disconnect();
- }, [isCurrentLayer, sections]);
+ }, [isCurrentLayer, mode, sections]);
useEffect(() => {
- if (!isCurrentLayer || !isMobile) return;
+ if (mode !== 'full' || !isCurrentLayer || !isMobile) return;
const scroller = mobileNavScrollerRef.current;
const button = mobileNavButtonRefs.current[activeSectionId];
if (!scroller || !button) return;
@@ -371,7 +535,7 @@ export function VisualConfigEditor({
left: targetLeft,
behavior: 'smooth',
});
- }, [activeSectionId, isCurrentLayer, isMobile]);
+ }, [activeSectionId, isCurrentLayer, isMobile, mode]);
const handleSectionJump = useCallback((sectionId: VisualSectionId) => {
setActiveSectionId(sectionId);
@@ -382,6 +546,106 @@ 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 hostField = (
+
+ onChange({ host: e.target.value })}
+ disabled={disabled}
+ />
+
+ );
+
+ const portField = (
+
+ onChange({ port: e.target.value })}
+ disabled={disabled}
+ error={portError}
+ />
+
+ );
+
+ const proxyUrlField = (
+
+ onChange({ proxyUrl: e.target.value })}
+ disabled={disabled}
+ />
+
+ );
+
+ const apiKeysField = (
+
+
+
+ );
+
+ const debugToggle = (
+
+ onChange({ debug })}
+ />
+
+ );
+
+ const loggingToFileToggle = (
+
+ onChange({ loggingToFile })}
+ />
+
+ );
+
+ const quotaSwitchProjectToggle = (
+
+ onChange({ quotaSwitchProject })}
+ />
+
+ );
+
+ const quotaSwitchPreviewModelToggle = (
+
+ onChange({ quotaSwitchPreviewModel })}
+ />
+
+ );
+
const navContent = (
{sections.map((section, index) => {
@@ -422,11 +686,33 @@ export function VisualConfigEditor({
+
+ handleModeChange('simple')}
+ aria-pressed={mode === 'simple'}
+ >
+
+ {t('config_management.visual.mode.simple')}
+
+ handleModeChange('full')}
+ aria-pressed={mode === 'full'}
+ >
+ {t('config_management.visual.mode.full')}
+
+
-
- {t('config_management.visual.quick_jump', { defaultValue: '快速跳转' })}
-
- {activeSection?.title}
+ {mode === 'full' && activeSection ? (
+ {activeSection.title}
+ ) : null}
{hasValidationIssues ? (
{t('config_management.visual.validation.validation_blocked')}
@@ -434,451 +720,351 @@ export function VisualConfigEditor({
) : null}
+
+
+
= 0
+ ? `${searchListboxId}-opt-${effectiveActiveIndex}`
+ : undefined
+ }
+ value={searchQuery}
+ onChange={(e) => {
+ setSearchQuery(e.target.value);
+ setSearchOpen(true);
+ setActiveResultIndex(0);
+ }}
+ onFocus={() => setSearchOpen(true)}
+ onKeyDown={(e) => {
+ // Ignore keys fired while an IME is composing (e.g. picking a Chinese
+ // candidate) — otherwise candidate selection triggers navigation/jump.
+ if (e.nativeEvent.isComposing) return;
+ if (e.key === 'Escape') {
+ setSearchOpen(false);
+ return;
+ }
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (!isResultsOpen) {
+ setSearchOpen(true);
+ return;
+ }
+ if (searchResults.length === 0) return;
+ setActiveResultIndex((effectiveActiveIndex + 1) % searchResults.length);
+ return;
+ }
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (!isResultsOpen) {
+ setSearchOpen(true);
+ return;
+ }
+ if (searchResults.length === 0) return;
+ setActiveResultIndex(
+ effectiveActiveIndex <= 0 ? searchResults.length - 1 : effectiveActiveIndex - 1
+ );
+ return;
+ }
+ if (e.key === 'Enter' && isResultsOpen && searchResults.length > 0) {
+ e.preventDefault();
+ handleResultJump(searchResults[effectiveActiveIndex] ?? searchResults[0]);
+ }
+ }}
+ rightElement={
+
+
+
+ }
+ />
+ {isResultsOpen ? (
+
+ {searchResults.length > 0 ? (
+ searchResults.map((entry, index) => (
+
setActiveResultIndex(index)}
+ onClick={() => 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}
+
-
- {isMobile ? (
-
-
- {sections.map((section, index) => (
-
{
- mobileNavButtonRefs.current[section.id] = node;
- }}
- type="button"
- className={`${styles.mobileSectionNavButton} ${
- activeSectionId === section.id ? styles.mobileSectionNavButtonActive : ''
- }`}
- onClick={() => handleSectionJump(section.id)}
- >
-
- {String(index + 1).padStart(2, '0')}
-
- {section.title}
- {section.errorCount > 0 ? (
-
- {section.errorCount}
-
- ) : null}
-
- ))}
+ {mode === 'simple' ? (
+
+ {hasHiddenValidationIssues ? (
+
+ {t('config_management.visual.mode.validation_banner')}
+ handleModeChange('full')}
+ >
+ {t('config_management.visual.mode.switch_to_full')}
+
+ ) : null}
+
+
+
{hostField}
+
{portField}
+ {apiKeysField}
+
{proxyUrlField}
+ {debugToggle}
+ {loggingToFileToggle}
+ {quotaSwitchProjectToggle}
+ {quotaSwitchPreviewModelToggle}
- ) : null}
-
-
-
- {
- sectionRefs.current.server = node;
- }}
- indexLabel="01"
- icon={ }
- title={t('config_management.visual.sections.server.title')}
- description={t('config_management.visual.sections.server.description')}
+ handleModeChange('full')}
>
-
-
- onChange({ host: e.target.value })}
- disabled={disabled}
- />
- onChange({ port: e.target.value })}
- disabled={disabled}
- error={portError}
- />
-
-
-
+
+ ) : (
+
+ {isMobile ? (
+
+
+ ) : null}
-
{
- sectionRefs.current.system = node;
- }}
- indexLabel="03"
- icon={ }
- title={t('config_management.visual.sections.system.title')}
- description={t('config_management.visual.sections.system.description')}
- >
-
-
- onChange({ debug })}
- />
- onChange({ commercialMode })}
- />
- onChange({ loggingToFile })}
- />
- onChange({ pluginsEnabled })}
- />
-
+
-
-
-
- {t('config_management.visual.sections.system.plugin_store_sources_label')}
-
-
+ {
+ sectionRefs.current.connectivity = node;
+ }}
+ indexLabel="01"
+ icon={ }
+ title={t('config_management.visual.sections.connectivity.title')}
+ description={t('config_management.visual.sections.connectivity.description')}
+ >
+
+
+ {hostField}
+ {portField}
+
+
+
+ onChange({ authDir: e.target.value })}
disabled={disabled}
- placeholder={t(
- 'config_management.visual.sections.system.plugin_store_sources_placeholder'
- )}
- inputAriaLabel={t(
- 'config_management.visual.sections.system.plugin_store_sources_label'
- )}
- onChange={handlePluginStoreSourcesChange}
+ hint={t('config_management.visual.sections.auth.auth_dir_hint')}
/>
-
- {t('config_management.visual.sections.system.plugin_store_sources_hint')}
-
-
-
+
-
- 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({ antigravitySignatureCacheEnabled })
- }
- />
-
- onChange({ antigravitySignatureBypassStrict })
- }
- />
-
+ {apiKeysField}
-
-
-
-
- {t('config_management.visual.sections.headers.claude_title')}
-
-
-
- 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 })
- }
- />
-
-
-
-
- {t('config_management.visual.sections.headers.codex_title')}
-
-
-
- onChange({ codexHeaderUserAgent: e.target.value })}
- disabled={disabled}
- />
- onChange({ codexHeaderBetaFeatures: e.target.value })}
- disabled={disabled}
- />
-
-
- onChange({ codexIdentityConfuse })}
- />
-
-
-
+
+
+
+ onChange({ tlsEnable })}
+ />
+
-
-
-
- onChange({ proxyUrl: e.target.value })}
- disabled={disabled}
- />
+ {values.tlsEnable ? (
+ <>
+
+
+
+ onChange({ tlsCert: e.target.value })}
+ disabled={disabled}
+ />
+
+
+ onChange({ tlsKey: e.target.value })}
+ disabled={disabled}
+ />
+
+
+ >
+ ) : null}
+
+
+
+
+
+
+
+ onChange({ rmAllowRemote })}
+ />
+
+
+ onChange({ rmDisableControlPanel })}
+ />
+
+
+
+ onChange({ rmDisableAutoUpdatePanel })
+ }
+ />
+
+
+
+
+ onChange({ rmSecretKey: e.target.value })}
+ disabled={disabled}
+ />
+
+
+ onChange({ rmPanelRepo: e.target.value })}
+ disabled={disabled}
+ />
+
+
+
+
+
+
+
+
{
+ sectionRefs.current.network = node;
+ }}
+ indexLabel="02"
+ icon={ }
+ title={t('config_management.visual.sections.network.title')}
+ description={t('config_management.visual.sections.network.description')}
+ >
+
+
+ {proxyUrlField}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
onChange({ routingSessionAffinityTTL: e.target.value })}
disabled={disabled}
/>
-
+
+
-
+
+
onChange({ forceModelPrefix })}
/>
+
+
onChange({ passthroughHeaders })}
/>
+
+
onChange({ disableCooling })}
/>
+
+
onChange({ routingSessionAffinity })}
/>
+
+
onChange({ wsAuth })}
/>
+
+
onChange({ enableGeminiCliEndpoint })}
/>
+
+
+
+
+
+
{
+ sectionRefs.current.logging = node;
+ }}
+ indexLabel="03"
+ icon={ }
+ title={t('config_management.visual.sections.logging.title')}
+ description={t('config_management.visual.sections.logging.description')}
+ >
+
+
+ {debugToggle}
+
+ 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({ usageStatisticsEnabled })}
+ />
+
+
+
+
+
+
{
+ sectionRefs.current.quota = node;
+ }}
+ indexLabel="04"
+ icon={ }
+ title={t('config_management.visual.sections.quota.title')}
+ description={t('config_management.visual.sections.quota.description')}
+ >
+
+ {quotaSwitchProjectToggle}
+ {quotaSwitchPreviewModelToggle}
+
+ onChange({ quotaAntigravityCredits })}
+ />
+
+
+
+
+
{
+ sectionRefs.current.streaming = node;
+ }}
+ indexLabel="05"
+ icon={ }
+ title={t('config_management.visual.sections.streaming.title')}
+ description={t('config_management.visual.sections.streaming.description')}
+ >
+
+
+
+
+
+
+ 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,
+ nonstreamKeepaliveInterval: e.target.value,
+ },
+ })
+ }
+ disabled={disabled}
+ />
+ {isNonstreamKeepaliveDisabled ? (
+
+ {t('config_management.visual.sections.streaming.disabled')}
+
+ ) : null}
+
+
+
+
+
+
+
+
{
+ sectionRefs.current.advanced = node;
+ }}
+ indexLabel="06"
+ icon={ }
+ title={t('config_management.visual.sections.advanced.title')}
+ description={t('config_management.visual.sections.advanced.description')}
+ >
+
+
+
+
+
+ onChange({ pluginsEnabled })}
+ />
+
+
+
+
+
+
+
+ {t(
+ 'config_management.visual.sections.system.plugin_store_sources_label'
+ )}
+
+
+
+ {t(
+ 'config_management.visual.sections.system.plugin_store_sources_hint'
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ onChange({ antigravitySignatureCacheEnabled })
+ }
+ />
+
+
+
+ onChange({ antigravitySignatureBypassStrict })
+ }
+ />
+
-
-
-
-
+
-
{
- sectionRefs.current.quota = node;
- }}
- indexLabel="04"
- icon={ }
- title={t('config_management.visual.sections.quota.title')}
- description={t('config_management.visual.sections.quota.description')}
- >
-
- onChange({ quotaSwitchProject })}
- />
- onChange({ quotaSwitchPreviewModel })}
- />
- onChange({ quotaAntigravityCredits })}
- />
-
-
-
-
{
- sectionRefs.current.streaming = node;
- }}
- indexLabel="05"
- icon={ }
- title={t('config_management.visual.sections.streaming.title')}
- description={t('config_management.visual.sections.streaming.description')}
- >
-
-
-
-
-
+
+
-
- onChange({
- streaming: {
- ...values.streaming,
- bootstrapRetries: e.target.value,
- },
- })
- }
- disabled={disabled}
- hint={t('config_management.visual.sections.streaming.bootstrap_hint')}
- error={bootstrapRetriesError}
- />
-
-
-
-
-
-
-
-
-
+
+
-
{
- sectionRefs.current.payload = node;
- }}
- indexLabel="06"
- icon={ }
- title={t('config_management.visual.sections.payload.title')}
- 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/components/ui/Collapsible/Collapsible.tsx b/src/components/ui/Collapsible/Collapsible.tsx
index 03d646ae..1325e5ce 100644
--- a/src/components/ui/Collapsible/Collapsible.tsx
+++ b/src/components/ui/Collapsible/Collapsible.tsx
@@ -1,4 +1,9 @@
-import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
+import {
+ useState,
+ type HTMLAttributes,
+ type PropsWithChildren,
+ type ReactNode,
+} from 'react';
import { IconChevronDown } from '../icons';
import styles from './Collapsible.module.scss';
@@ -22,12 +27,23 @@ export function Collapsible({
className,
...rest
}: PropsWithChildren) {
- const detailsProps =
- open !== undefined ? { open } : { defaultOpen };
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
+ const resolvedOpen = open ?? uncontrolledOpen;
const cls = [styles.root, className].filter(Boolean).join(' ');
const contentCls = flush ? styles.contentFlush : styles.content;
+
return (
-
+ {
+ if (open === undefined) {
+ setUncontrolledOpen(event.currentTarget.open);
+ }
+ onToggle?.(event);
+ }}
+ {...rest}
+ >
{label}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 2b1c8697..f9ede766 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -909,7 +909,33 @@
"visual": {
"notice": "Visual mode covers common fields. Review or edit unsupported config.yaml entries in source mode.",
"quick_jump": "Quick Jump",
+ "mode": {
+ "simple": "Simple",
+ "full": "Full",
+ "label": "Editor mode",
+ "more_settings": "Switch to full mode to see all {{total}} sections",
+ "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",
+ "description": "Server address, port, auth directory, and API keys"
+ },
+ "logging": {
+ "title": "Logging & Diagnostics",
+ "description": "Debug, log output, and usage statistics"
+ },
+ "advanced": {
+ "title": "Advanced & Experimental",
+ "description": "Header spoofing, signature cache, plugin sources, and other rarely changed settings",
+ "plugins_title": "Plugins",
+ "signature_title": "Antigravity Signature"
+ },
"server": {
"title": "Server Configuration",
"description": "Basic server settings",
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index ad3f1647..86cd97c8 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -896,7 +896,33 @@
"visual": {
"notice": "Визуальный режим охватывает основные поля. Остальные параметры config.yaml по-прежнему нужно проверять или редактировать в режиме исходника.",
"quick_jump": "Быстрый переход",
+ "mode": {
+ "simple": "Простой",
+ "full": "Полный",
+ "label": "Режим редактора",
+ "more_settings": "Переключитесь в полный режим, чтобы увидеть все разделы ({{total}})",
+ "validation_banner": "В некоторых дополнительных параметрах есть ошибки проверки, сохранение заблокировано. Переключитесь в полный режим, чтобы исправить их.",
+ "switch_to_full": "Перейти в полный режим"
+ },
+ "search": {
+ "placeholder": "Поиск настроек (по названию или ключу YAML)",
+ "no_results": "Нет подходящих настроек"
+ },
"sections": {
+ "connectivity": {
+ "title": "Доступ и аутентификация",
+ "description": "Адрес сервера, порт, каталог аутентификации и ключи API"
+ },
+ "logging": {
+ "title": "Логи и диагностика",
+ "description": "Отладка, вывод логов и статистика использования"
+ },
+ "advanced": {
+ "title": "Дополнительно и эксперименты",
+ "description": "Подмена заголовков, кэш подписей, источники плагинов и другие редко изменяемые настройки",
+ "plugins_title": "Плагины",
+ "signature_title": "Подпись Antigravity"
+ },
"server": {
"title": "Настройки сервера",
"description": "Базовые параметры сервера",
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index 34d5032e..0bc00c59 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -909,7 +909,33 @@
"visual": {
"notice": "可视化模式覆盖常用字段,未覆盖的配置仍需在源文件模式中查看或编辑。",
"quick_jump": "快速跳转",
+ "mode": {
+ "simple": "简单",
+ "full": "完整",
+ "label": "编辑模式",
+ "more_settings": "切换到完整模式,查看全部 {{total}} 个配置分区",
+ "validation_banner": "部分高级配置项存在校验错误,已阻止保存。请切换到完整模式进行修复。",
+ "switch_to_full": "切换完整模式"
+ },
+ "search": {
+ "placeholder": "搜索配置项(标签或 YAML 键名)",
+ "no_results": "没有匹配的配置项"
+ },
"sections": {
+ "connectivity": {
+ "title": "接入与认证",
+ "description": "服务地址、端口、认证目录与 API 密钥"
+ },
+ "logging": {
+ "title": "日志与诊断",
+ "description": "调试、日志输出与使用统计"
+ },
+ "advanced": {
+ "title": "高级与实验",
+ "description": "请求头伪装、签名缓存、插件源等不常修改的设置",
+ "plugins_title": "插件",
+ "signature_title": "Antigravity 签名"
+ },
"server": {
"title": "服务器配置",
"description": "基础服务器设置",
diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json
index 546d6de4..8606e90a 100644
--- a/src/i18n/locales/zh-TW.json
+++ b/src/i18n/locales/zh-TW.json
@@ -935,7 +935,33 @@
"visual": {
"notice": "視覺化模式涵蓋常用欄位,未涵蓋的設定仍需在原始檔模式中查看或編輯。",
"quick_jump": "快速跳轉",
+ "mode": {
+ "simple": "簡單",
+ "full": "完整",
+ "label": "編輯模式",
+ "more_settings": "切換到完整模式,檢視全部 {{total}} 個設定分區",
+ "validation_banner": "部分進階設定項存在驗證錯誤,已阻止儲存。請切換到完整模式進行修正。",
+ "switch_to_full": "切換完整模式"
+ },
+ "search": {
+ "placeholder": "搜尋設定項(標籤或 YAML 鍵名)",
+ "no_results": "沒有符合的設定項"
+ },
"sections": {
+ "connectivity": {
+ "title": "接入與認證",
+ "description": "服務位址、連接埠、認證目錄與 API 金鑰"
+ },
+ "logging": {
+ "title": "日誌與診斷",
+ "description": "除錯、日誌輸出與使用統計"
+ },
+ "advanced": {
+ "title": "進階與實驗",
+ "description": "請求標頭偽裝、簽章快取、外掛來源等不常修改的設定",
+ "plugins_title": "外掛",
+ "signature_title": "Antigravity 簽章"
+ },
"server": {
"title": "伺服器設定",
"description": "基本伺服器設定",