Compare commits

...

12 Commits

23 changed files with 917 additions and 154 deletions
@@ -229,6 +229,90 @@
@media (max-width: 1024px) {
grid-template-columns: minmax(0, 1fr);
}
@include mobile {
gap: 16px;
}
}
.mobileSectionNav {
display: none;
@include mobile {
position: sticky;
top: calc(var(--header-height, 64px) + 12px);
z-index: 4;
display: block;
margin-bottom: 4px;
}
}
.mobileSectionNavScroller {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 4px 2px 10px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.mobileSectionNavButton {
@include button-reset;
display: inline-flex;
align-items: center;
gap: 8px;
min-width: max-content;
flex: 0 0 auto;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
background: color-mix(in srgb, var(--bg-primary) 88%, transparent);
box-shadow: 0 18px 36px -30px rgba(0, 0, 0, 0.28);
white-space: nowrap;
}
.mobileSectionNavButtonActive {
border-color: color-mix(in srgb, var(--primary-color) 24%, var(--border-color));
background: color-mix(in srgb, var(--bg-primary) 96%, transparent);
}
.mobileSectionNavIndex {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
}
.mobileSectionNavLabel {
color: var(--text-primary);
font-size: 13px;
font-weight: 700;
}
.mobileSectionNavBadge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
background: var(--warning-bg);
border: 1px solid var(--warning-border);
color: var(--warning-text);
font-size: 11px;
font-weight: 700;
}
.sidebar {
@@ -447,6 +531,10 @@
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
@include mobile {
grid-template-columns: minmax(0, 1fr);
}
}
.sectionStack {
@@ -729,9 +817,19 @@
}
@include mobile {
.overview {
gap: 14px;
padding: 18px;
border-radius: 24px;
}
.overviewFocusLink {
padding: 12px 14px;
}
.sections {
border-radius: 26px;
padding: 18px;
padding: 16px;
}
.subsection {
@@ -739,7 +837,41 @@
border-radius: 20px;
}
.toggleRow {
padding: 14px 16px;
}
.blockHeaderRow,
.ruleCardHeader {
align-items: stretch;
}
.blockHeaderRow :global(.btn),
.ruleCardHeader :global(.btn),
.actionRow :global(.btn),
.stringListRow :global(.btn) {
width: 100%;
justify-content: center;
}
.actionRow {
justify-content: stretch;
}
.stringListRow {
align-items: stretch;
}
}
@media (max-width: 380px) {
.overview,
.sections {
padding: 14px;
}
.subsection,
.ruleCard,
.toggleRow {
padding: 14px;
}
}
+66 -3
View File
@@ -11,6 +11,7 @@ import {
} from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
@@ -177,8 +178,11 @@ export function VisualConfigEditor({
onChange,
}: VisualConfigEditorProps) {
const { t } = useTranslation();
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const isMobile = useMediaQuery('(max-width: 768px)');
const isFloatingSidebar = useMediaQuery('(min-width: 1025px)');
const shouldRenderFloatingSidebar = !isMobile && isFloatingSidebar && isCurrentLayer;
const routingStrategyLabelId = useId();
const routingStrategyHintId = `${routingStrategyLabelId}-hint`;
const keepaliveInputId = useId();
@@ -192,6 +196,10 @@ export function VisualConfigEditor({
const sidebarAnchorRef = useRef<HTMLElement | null>(null);
const floatingSidebarRef = useRef<HTMLDivElement | null>(null);
const sectionRefs = useRef<Partial<Record<VisualSectionId, HTMLElement | null>>>({});
const mobileNavScrollerRef = useRef<HTMLDivElement | null>(null);
const mobileNavButtonRefs = useRef<Partial<Record<VisualSectionId, HTMLButtonElement | null>>>(
{}
);
const isKeepaliveDisabled =
values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0';
@@ -351,6 +359,27 @@ export function VisualConfigEditor({
return () => observer.disconnect();
}, [sections]);
useEffect(() => {
if (!isMobile) return;
const scroller = mobileNavScrollerRef.current;
const button = mobileNavButtonRefs.current[activeSectionId];
if (!scroller || !button) return;
const scrollerRect = scroller.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
const centeredLeft =
scroller.scrollLeft +
(buttonRect.left - scrollerRect.left) -
(scroller.clientWidth - buttonRect.width) / 2;
const maxScrollLeft = Math.max(scroller.scrollWidth - scroller.clientWidth, 0);
const targetLeft = Math.min(Math.max(centeredLeft, 0), maxScrollLeft);
scroller.scrollTo({
left: targetLeft,
behavior: 'smooth',
});
}, [activeSectionId, isMobile]);
const handleSectionJump = useCallback((sectionId: VisualSectionId) => {
setActiveSectionId(sectionId);
sectionRefs.current[sectionId]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -371,7 +400,7 @@ export function VisualConfigEditor({
floatingElement.style.removeProperty('pointer-events');
};
if (isMobile || !isFloatingSidebar || !anchorElement || !workspaceElement) {
if (!shouldRenderFloatingSidebar || !anchorElement || !workspaceElement) {
clearFloatingStyles();
return undefined;
}
@@ -441,7 +470,7 @@ export function VisualConfigEditor({
contentScroller?.removeEventListener('scroll', requestPositionUpdate);
clearFloatingStyles();
};
}, [isFloatingSidebar, isMobile]);
}, [shouldRenderFloatingSidebar]);
const navContent = (
<div className={styles.navList}>
@@ -528,6 +557,40 @@ export function VisualConfigEditor({
</div>
<div ref={workspaceRef} className={styles.workspace}>
{isMobile ? (
<div className={styles.mobileSectionNav}>
<div
ref={mobileNavScrollerRef}
className={styles.mobileSectionNavScroller}
aria-label={t('config_management.visual.quick_jump', { defaultValue: '快速跳转' })}
>
{sections.map((section, index) => (
<button
key={section.id}
ref={(node) => {
mobileNavButtonRefs.current[section.id] = node;
}}
type="button"
className={`${styles.mobileSectionNavButton} ${
activeSectionId === section.id ? styles.mobileSectionNavButtonActive : ''
}`}
onClick={() => handleSectionJump(section.id)}
>
<span className={styles.mobileSectionNavIndex}>
{String(index + 1).padStart(2, '0')}
</span>
<span className={styles.mobileSectionNavLabel}>{section.title}</span>
{section.errorCount > 0 ? (
<span className={styles.mobileSectionNavBadge} aria-hidden="true">
{section.errorCount}
</span>
) : null}
</button>
))}
</div>
</div>
) : null}
<aside ref={sidebarAnchorRef} className={styles.sidebar}>
{isFloatingSidebar ? (
<div className={styles.sidebarPlaceholder} aria-hidden="true" />
@@ -1042,7 +1105,7 @@ export function VisualConfigEditor({
</div>
</div>
{!isMobile && isFloatingSidebar && typeof document !== 'undefined'
{shouldRenderFloatingSidebar && typeof document !== 'undefined'
? createPortal(
<div ref={floatingSidebarRef} className={styles.floatingSidebarContainer}>
<div className={styles.floatingSidebarRail}>{navContent}</div>
+1 -1
View File
@@ -277,7 +277,7 @@ export function Modal({
<div
ref={modalRef}
className={modalClass}
style={{ width }}
style={{ width, maxWidth: '100%' }}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
+1 -1
View File
@@ -44,7 +44,7 @@ export function SelectionCheckbox({
disabled={disabled}
/>
<span className={boxClassName}>{checked ? <IconCheck size={12} /> : null}</span>
{label ? <span className={textClassName}>{label}</span> : null}
{label ? <div className={textClassName}>{label}</div> : null}
</label>
);
}
+11
View File
@@ -218,6 +218,9 @@
"gemini_models_fetch_empty": "No models returned. Please check Base URL, API key, or headers.",
"gemini_models_fetch_error": "Failed to fetch Gemini models",
"gemini_models_fetch_apply": "Add selected models",
"model_discovery_select_visible": "Select current list",
"model_discovery_clear_selection": "Clear selection",
"model_discovery_selected_count": "{{count}} selected",
"gemini_models_search_label": "Search models",
"gemini_models_search_placeholder": "Filter by name, alias, or description",
"gemini_models_search_empty": "No models match your search. Try a different keyword.",
@@ -1130,16 +1133,24 @@
"description": "Edit config.yaml via visual editor or source file",
"status_idle": "Waiting for action",
"status_loading": "Loading configuration...",
"status_loading_short": "Loading",
"status_loaded": "Configuration loaded",
"status_loaded_short": "Loaded",
"status_dirty": "Unsaved changes",
"status_dirty_short": "Unsaved",
"status_disconnected": "Connect to the server to load the configuration",
"status_disconnected_short": "Disconnected",
"status_load_failed": "Load failed",
"status_load_failed_short": "Failed",
"status_saving": "Saving configuration...",
"status_saving_short": "Saving",
"status_saved": "Configuration saved",
"status_save_failed": "Save failed",
"save_success": "Configuration saved successfully",
"error_yaml_not_supported": "Server did not return YAML. Verify the /config.yaml endpoint is available.",
"visual_mode_unavailable": "Visual editor unavailable until YAML syntax is fixed",
"visual_mode_unavailable_short": "YAML issue",
"validation_blocked_short": "Fix errors",
"visual_mode_unavailable_detail": "Visual editor is unavailable because the configuration contains invalid YAML: {{message}}",
"visual_mode_save_blocked": "Cannot save from visual mode until the YAML syntax is fixed",
"visual_mode_latest_yaml_invalid": "The latest server configuration contains invalid YAML. Review it in source mode before saving visual changes: {{message}}",
+11
View File
@@ -218,6 +218,9 @@
"gemini_models_fetch_empty": "Модели не получены. Проверьте Base URL, API-ключ или заголовки.",
"gemini_models_fetch_error": "Не удалось получить модели Gemini",
"gemini_models_fetch_apply": "Добавить выбранные модели",
"model_discovery_select_visible": "Выбрать текущий список",
"model_discovery_clear_selection": "Очистить выбор",
"model_discovery_selected_count": "Выбрано: {{count}}",
"gemini_models_search_label": "Поиск моделей",
"gemini_models_search_placeholder": "Фильтр по имени, псевдониму или описанию",
"gemini_models_search_empty": "Нет подходящих моделей. Попробуйте другой запрос.",
@@ -1133,16 +1136,24 @@
"description": "Редактируйте config.yaml через визуальный редактор или исходный файл",
"status_idle": "Ожидание действия",
"status_loading": "Загрузка конфигурации...",
"status_loading_short": "Загрузка",
"status_loaded": "Конфигурация загружена",
"status_loaded_short": "Загружено",
"status_dirty": "Есть несохранённые изменения",
"status_dirty_short": "Несохранено",
"status_disconnected": "Подключитесь к серверу, чтобы загрузить конфигурацию",
"status_disconnected_short": "Нет связи",
"status_load_failed": "Не удалось загрузить",
"status_load_failed_short": "Ошибка",
"status_saving": "Сохранение конфигурации...",
"status_saving_short": "Сохранение",
"status_saved": "Конфигурация сохранена",
"status_save_failed": "Не удалось сохранить",
"save_success": "Конфигурация успешно сохранена",
"error_yaml_not_supported": "Сервер не вернул YAML. Убедитесь, что доступна конечная точка /config.yaml.",
"visual_mode_unavailable": "Визуальный редактор недоступен, пока не исправлен синтаксис YAML",
"visual_mode_unavailable_short": "Ошибка YAML",
"validation_blocked_short": "Есть ошибки",
"visual_mode_unavailable_detail": "Визуальный редактор недоступен, потому что в конфигурации есть некорректный YAML: {{message}}",
"visual_mode_save_blocked": "Нельзя сохранять из визуального режима, пока не исправлен синтаксис YAML",
"visual_mode_latest_yaml_invalid": "Последняя конфигурация на сервере содержит некорректный YAML. Проверьте её в режиме исходника перед сохранением визуальных изменений: {{message}}",
+11
View File
@@ -218,6 +218,9 @@
"gemini_models_fetch_empty": "未获取到模型,请检查 Base URL、API Key 或请求头。",
"gemini_models_fetch_error": "获取 Gemini 模型失败",
"gemini_models_fetch_apply": "添加所选模型",
"model_discovery_select_visible": "全选当前列表",
"model_discovery_clear_selection": "清空已选",
"model_discovery_selected_count": "已选 {{count}} 项",
"gemini_models_search_label": "搜索模型",
"gemini_models_search_placeholder": "按名称、别名或描述筛选",
"gemini_models_search_empty": "没有匹配的模型,请更换关键字试试。",
@@ -1130,16 +1133,24 @@
"description": "通过可视化或者源文件方式编辑 config.yaml 配置文件",
"status_idle": "等待操作",
"status_loading": "加载配置中...",
"status_loading_short": "加载中",
"status_loaded": "配置已加载",
"status_loaded_short": "已加载",
"status_dirty": "有未保存的更改",
"status_dirty_short": "未保存",
"status_disconnected": "请先连接服务器以加载配置",
"status_disconnected_short": "未连接",
"status_load_failed": "加载失败",
"status_load_failed_short": "失败",
"status_saving": "正在保存配置...",
"status_saving_short": "保存中",
"status_saved": "配置保存完成",
"status_save_failed": "保存失败",
"save_success": "配置已保存",
"error_yaml_not_supported": "服务器未返回 YAML 格式,请确认 /config.yaml 接口可用",
"visual_mode_unavailable": "YAML 语法修复前无法使用可视化编辑",
"visual_mode_unavailable_short": "YAML错误",
"validation_blocked_short": "待修复",
"visual_mode_unavailable_detail": "当前配置存在无效 YAML,暂时无法使用可视化编辑:{{message}}",
"visual_mode_save_blocked": "请先修复 YAML 语法错误,再从可视化模式保存",
"visual_mode_latest_yaml_invalid": "服务端最新配置包含无效 YAML,请先切回源码模式检查后再保存可视化修改:{{message}}",
+90 -19
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { modelsApi } from '@/services/api';
@@ -48,6 +49,14 @@ export function AiProvidersClaudeModelsPage() {
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
});
}, [models, search]);
const visibleModelNames = useMemo(
() => filteredModels.map((model) => model.name),
[filteredModels]
);
const allVisibleSelected = useMemo(
() => visibleModelNames.length > 0 && visibleModelNames.every((name) => selected.has(name)),
[selected, visibleModelNames]
);
const fetchClaudeModelDiscovery = useCallback(async () => {
setFetching(true);
@@ -117,6 +126,22 @@ export function AiProvidersClaudeModelsPage() {
void fetchClaudeModelDiscovery();
}, [fetchClaudeModelDiscovery, form.apiKey, form.baseUrl, form.headers, initialLoading]);
useEffect(() => {
const availableNames = new Set(models.map((model) => model.name));
setSelected((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (availableNames.has(name)) {
next.add(name);
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [models]);
const handleBack = useCallback(() => {
navigate(-1);
}, [navigate]);
@@ -145,6 +170,18 @@ export function AiProvidersClaudeModelsPage() {
});
};
const handleSelectVisible = useCallback(() => {
setSelected((prev) => {
const next = new Set(prev);
visibleModelNames.forEach((name) => next.add(name));
return next;
});
}, [visibleModelNames]);
const handleClearSelection = useCallback(() => {
setSelected(new Set());
}, []);
const handleApply = () => {
const selectedModels = models.filter((model) => selected.has(model.name));
if (selectedModels.length) {
@@ -153,7 +190,7 @@ export function AiProvidersClaudeModelsPage() {
handleBack();
};
const canApply = !disableControls && !saving && !fetching;
const canApply = !disableControls && !saving && !fetching && selected.size > 0;
return (
<SecondaryScreenShell
@@ -219,9 +256,42 @@ export function AiProvidersClaudeModelsPage() {
onChange={(e) => setSearch(e.target.value)}
disabled={fetching}
/>
{models.length > 0 && (
<div className={styles.modelDiscoveryToolbar}>
<div className={styles.modelDiscoveryToolbarActions}>
<Button
variant="secondary"
size="sm"
onClick={handleSelectVisible}
disabled={
disableControls ||
saving ||
fetching ||
filteredModels.length === 0 ||
allVisibleSelected
}
>
{t('ai_providers.model_discovery_select_visible')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClearSelection}
disabled={disableControls || saving || fetching || selected.size === 0}
>
{t('ai_providers.model_discovery_clear_selection')}
</Button>
</div>
<div className={styles.modelDiscoverySelectionSummary}>
{t('ai_providers.model_discovery_selected_count', { count: selected.size })}
</div>
</div>
)}
{error && <div className="error-box">{error}</div>}
{fetching ? (
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_loading')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.claude_models_fetch_loading')}
</div>
) : models.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_empty')}</div>
) : filteredModels.length === 0 ? (
@@ -231,29 +301,30 @@ export function AiProvidersClaudeModelsPage() {
{filteredModels.map((model) => {
const checked = selected.has(model.name);
return (
<label
<SelectionCheckbox
key={model.name}
checked={checked}
onChange={() => toggleSelection(model.name)}
disabled={disableControls || saving || fetching}
ariaLabel={model.name}
className={`${styles.modelDiscoveryRow} ${
checked ? styles.modelDiscoveryRowSelected : ''
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleSelection(model.name)}
/>
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
labelClassName={styles.modelDiscoverySelectionLabel}
label={
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
</label>
}
/>
);
})}
</div>
+131 -29
View File
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { Modal } from '@/components/ui/Modal';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
@@ -66,7 +67,9 @@ const buildCodexSignature = (form: ProviderFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
websockets: Boolean(form.websockets),
@@ -95,7 +98,9 @@ export function AiProvidersCodexEditPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
const [baselineSignature, setBaselineSignature] = useState(() => buildCodexSignature(buildEmptyForm()));
const [baselineSignature, setBaselineSignature] = useState(() =>
buildCodexSignature(buildEmptyForm())
);
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
@@ -119,7 +124,9 @@ export function AiProvidersCodexEditPage() {
const invalidIndex = editIndex !== null && !initialData;
const title =
editIndex !== null ? t('ai_providers.codex_edit_modal_title') : t('ai_providers.codex_add_modal_title');
editIndex !== null
? t('ai_providers.codex_edit_modal_title')
: t('ai_providers.codex_add_modal_title');
const handleBack = useCallback(() => {
const state = location.state as LocationState;
@@ -216,6 +223,16 @@ export function AiProvidersCodexEditPage() {
return name.includes(filter) || alias.includes(filter) || description.includes(filter);
});
}, [discoveredModels, modelDiscoverySearch]);
const visibleDiscoveredModelNames = useMemo(
() => discoveredModelsFiltered.map((model) => model.name),
[discoveredModelsFiltered]
);
const allVisibleDiscoveredSelected = useMemo(
() =>
visibleDiscoveredModelNames.length > 0 &&
visibleDiscoveredModelNames.every((name) => modelDiscoverySelected.has(name)),
[modelDiscoverySelected, visibleDiscoveredModelNames]
);
const mergeDiscoveredModels = useCallback(
(selectedModels: ModelInfo[]) => {
@@ -247,7 +264,10 @@ export function AiProvidersCodexEditPage() {
});
if (addedCount > 0) {
showNotification(t('ai_providers.codex_models_fetch_added', { count: addedCount }), 'success');
showNotification(
t('ai_providers.codex_models_fetch_added', { count: addedCount }),
'success'
);
}
},
[setForm, showNotification, t]
@@ -320,6 +340,22 @@ export function AiProvidersCodexEditPage() {
void fetchCodexModelDiscovery();
}, [fetchCodexModelDiscovery, form.apiKey, form.baseUrl, form.headers, modelDiscoveryOpen]);
useEffect(() => {
const availableNames = new Set(discoveredModels.map((model) => model.name));
setModelDiscoverySelected((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (availableNames.has(name)) {
next.add(name);
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [discoveredModels]);
const toggleModelDiscoverySelection = (name: string) => {
setModelDiscoverySelected((prev) => {
const next = new Set(prev);
@@ -332,8 +368,22 @@ export function AiProvidersCodexEditPage() {
});
};
const handleSelectVisibleDiscoveredModels = useCallback(() => {
setModelDiscoverySelected((prev) => {
const next = new Set(prev);
visibleDiscoveredModelNames.forEach((name) => next.add(name));
return next;
});
}, [visibleDiscoveredModelNames]);
const handleClearDiscoveredModelSelection = useCallback(() => {
setModelDiscoverySelected(new Set());
}, []);
const handleApplyDiscoveredModels = () => {
const selectedModels = discoveredModels.filter((model) => modelDiscoverySelected.has(model.name));
const selectedModels = discoveredModels.filter((model) =>
modelDiscoverySelected.has(model.name)
);
if (selectedModels.length) {
mergeDiscoveredModels(selectedModels);
}
@@ -374,7 +424,9 @@ export function AiProvidersCodexEditPage() {
updateConfigValue('codex-api-key', nextList);
clearCache('codex-api-key');
showNotification(
editIndex !== null ? t('notification.codex_config_updated') : t('notification.codex_config_added'),
editIndex !== null
? t('notification.codex_config_updated')
: t('notification.codex_config_added'),
'success'
);
allowNextNavigation();
@@ -407,7 +459,8 @@ export function AiProvidersCodexEditPage() {
!invalidIndexParam &&
!invalidIndex &&
Boolean((form.baseUrl ?? '').trim());
const canApplyModelDiscovery = !disableControls && !saving && !modelDiscoveryFetching;
const canApplyModelDiscovery =
!disableControls && !saving && !modelDiscoveryFetching && modelDiscoverySelected.size > 0;
return (
<SecondaryScreenShell
@@ -514,7 +567,9 @@ export function AiProvidersCodexEditPage() {
<div className={styles.modelConfigSection}>
<div className={styles.modelConfigHeader}>
<label className={styles.modelConfigTitle}>{t('ai_providers.codex_models_label')}</label>
<label className={styles.modelConfigTitle}>
{t('ai_providers.codex_models_label')}
</label>
<div className={styles.modelConfigToolbar}>
<Button
variant="secondary"
@@ -595,7 +650,9 @@ export function AiProvidersCodexEditPage() {
}
>
<div className={styles.openaiModelsContent}>
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_hint')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.codex_models_fetch_hint')}
</div>
<div className={styles.openaiModelsEndpointSection}>
<label className={styles.openaiModelsEndpointLabel}>
{t('ai_providers.codex_models_fetch_url_label')}
@@ -624,41 +681,86 @@ export function AiProvidersCodexEditPage() {
onChange={(e) => setModelDiscoverySearch(e.target.value)}
disabled={modelDiscoveryFetching}
/>
{discoveredModels.length > 0 && (
<div className={styles.modelDiscoveryToolbar}>
<div className={styles.modelDiscoveryToolbarActions}>
<Button
variant="secondary"
size="sm"
onClick={handleSelectVisibleDiscoveredModels}
disabled={
disableControls ||
saving ||
modelDiscoveryFetching ||
discoveredModelsFiltered.length === 0 ||
allVisibleDiscoveredSelected
}
>
{t('ai_providers.model_discovery_select_visible')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClearDiscoveredModelSelection}
disabled={
disableControls ||
saving ||
modelDiscoveryFetching ||
modelDiscoverySelected.size === 0
}
>
{t('ai_providers.model_discovery_clear_selection')}
</Button>
</div>
<div className={styles.modelDiscoverySelectionSummary}>
{t('ai_providers.model_discovery_selected_count', {
count: modelDiscoverySelected.size,
})}
</div>
</div>
)}
{modelDiscoveryError && <div className="error-box">{modelDiscoveryError}</div>}
{modelDiscoveryFetching ? (
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_loading')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.codex_models_fetch_loading')}
</div>
) : discoveredModels.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_empty')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.codex_models_fetch_empty')}
</div>
) : discoveredModelsFiltered.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.codex_models_search_empty')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.codex_models_search_empty')}
</div>
) : (
<div className={styles.modelDiscoveryList}>
{discoveredModelsFiltered.map((model) => {
const checked = modelDiscoverySelected.has(model.name);
return (
<label
<SelectionCheckbox
key={model.name}
checked={checked}
onChange={() => toggleModelDiscoverySelection(model.name)}
disabled={disableControls || saving || modelDiscoveryFetching}
ariaLabel={model.name}
className={`${styles.modelDiscoveryRow} ${
checked ? styles.modelDiscoveryRowSelected : ''
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleModelDiscoverySelection(model.name)}
/>
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
labelClassName={styles.modelDiscoverySelectionLabel}
label={
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
</label>
}
/>
);
})}
</div>
+141 -32
View File
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { Modal } from '@/components/ui/Modal';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
@@ -42,7 +43,9 @@ const parseIndexParam = (value: string | undefined) => {
};
const stripGeminiModelResourceName = (value: string) => {
return String(value ?? '').trim().replace(/^\/?models\//i, '');
return String(value ?? '')
.trim()
.replace(/^\/?models\//i, '');
};
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
@@ -61,7 +64,9 @@ const buildGeminiSignature = (form: GeminiFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
proxyUrl: String(form.proxyUrl ?? '').trim(),
@@ -89,7 +94,9 @@ export function AiProvidersGeminiEditPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<GeminiFormState>(() => buildEmptyForm());
const [baselineSignature, setBaselineSignature] = useState(() => buildGeminiSignature(buildEmptyForm()));
const [baselineSignature, setBaselineSignature] = useState(() =>
buildGeminiSignature(buildEmptyForm())
);
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
@@ -113,7 +120,9 @@ export function AiProvidersGeminiEditPage() {
const invalidIndex = editIndex !== null && !initialData;
const title =
editIndex !== null ? t('ai_providers.gemini_edit_modal_title') : t('ai_providers.gemini_add_modal_title');
editIndex !== null
? t('ai_providers.gemini_edit_modal_title')
: t('ai_providers.gemini_add_modal_title');
const handleBack = useCallback(() => {
const state = location.state as LocationState;
@@ -196,6 +205,16 @@ export function AiProvidersGeminiEditPage() {
return name.includes(filter) || alias.includes(filter) || description.includes(filter);
});
}, [discoveredModels, modelDiscoverySearch]);
const visibleDiscoveredModelNames = useMemo(
() => discoveredModelsFiltered.map((model) => model.name),
[discoveredModelsFiltered]
);
const allVisibleDiscoveredSelected = useMemo(
() =>
visibleDiscoveredModelNames.length > 0 &&
visibleDiscoveredModelNames.every((name) => modelDiscoverySelected.has(name)),
[modelDiscoverySelected, visibleDiscoveredModelNames]
);
const mergeDiscoveredModels = useCallback(
(selectedModels: ModelInfo[]) => {
@@ -225,7 +244,10 @@ export function AiProvidersGeminiEditPage() {
});
if (addedCount > 0) {
showNotification(t('ai_providers.gemini_models_fetch_added', { count: addedCount }), 'success');
showNotification(
t('ai_providers.gemini_models_fetch_added', { count: addedCount }),
'success'
);
}
},
[setForm, showNotification, t]
@@ -306,6 +328,22 @@ export function AiProvidersGeminiEditPage() {
void fetchGeminiModelDiscovery();
}, [fetchGeminiModelDiscovery, form.apiKey, form.baseUrl, form.headers, modelDiscoveryOpen]);
useEffect(() => {
const availableNames = new Set(discoveredModels.map((model) => model.name));
setModelDiscoverySelected((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (availableNames.has(name)) {
next.add(name);
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [discoveredModels]);
const toggleModelDiscoverySelection = (name: string) => {
setModelDiscoverySelected((prev) => {
const next = new Set(prev);
@@ -318,8 +356,22 @@ export function AiProvidersGeminiEditPage() {
});
};
const handleSelectVisibleDiscoveredModels = useCallback(() => {
setModelDiscoverySelected((prev) => {
const next = new Set(prev);
visibleDiscoveredModelNames.forEach((name) => next.add(name));
return next;
});
}, [visibleDiscoveredModelNames]);
const handleClearDiscoveredModelSelection = useCallback(() => {
setModelDiscoverySelected(new Set());
}, []);
const handleApplyDiscoveredModels = () => {
const selectedModels = discoveredModels.filter((model) => modelDiscoverySelected.has(model.name));
const selectedModels = discoveredModels.filter((model) =>
modelDiscoverySelected.has(model.name)
);
if (selectedModels.length) {
mergeDiscoveredModels(selectedModels);
}
@@ -374,7 +426,9 @@ export function AiProvidersGeminiEditPage() {
updateConfigValue('gemini-api-key', nextList);
clearCache('gemini-api-key');
showNotification(
editIndex !== null ? t('notification.gemini_key_updated') : t('notification.gemini_key_added'),
editIndex !== null
? t('notification.gemini_key_updated')
: t('notification.gemini_key_added'),
'success'
);
allowNextNavigation();
@@ -400,8 +454,10 @@ export function AiProvidersGeminiEditPage() {
updateConfigValue,
]);
const canOpenModelDiscovery = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
const canApplyModelDiscovery = !disableControls && !saving && !modelDiscoveryFetching;
const canOpenModelDiscovery =
!disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
const canApplyModelDiscovery =
!disableControls && !saving && !modelDiscoveryFetching && modelDiscoverySelected.size > 0;
return (
<SecondaryScreenShell
@@ -501,7 +557,9 @@ export function AiProvidersGeminiEditPage() {
<div className={styles.modelConfigSection}>
<div className={styles.modelConfigHeader}>
<label className={styles.modelConfigTitle}>{t('ai_providers.gemini_models_label')}</label>
<label className={styles.modelConfigTitle}>
{t('ai_providers.gemini_models_label')}
</label>
<div className={styles.modelConfigToolbar}>
<Button
variant="secondary"
@@ -572,14 +630,20 @@ export function AiProvidersGeminiEditPage() {
>
{t('common.cancel')}
</Button>
<Button size="sm" onClick={handleApplyDiscoveredModels} disabled={!canApplyModelDiscovery}>
<Button
size="sm"
onClick={handleApplyDiscoveredModels}
disabled={!canApplyModelDiscovery}
>
{t('ai_providers.gemini_models_fetch_apply')}
</Button>
</>
}
>
<div className={styles.openaiModelsContent}>
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_hint')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.gemini_models_fetch_hint')}
</div>
<div className={styles.openaiModelsEndpointSection}>
<label className={styles.openaiModelsEndpointLabel}>
{t('ai_providers.gemini_models_fetch_url_label')}
@@ -608,41 +672,86 @@ export function AiProvidersGeminiEditPage() {
onChange={(e) => setModelDiscoverySearch(e.target.value)}
disabled={modelDiscoveryFetching}
/>
{discoveredModels.length > 0 && (
<div className={styles.modelDiscoveryToolbar}>
<div className={styles.modelDiscoveryToolbarActions}>
<Button
variant="secondary"
size="sm"
onClick={handleSelectVisibleDiscoveredModels}
disabled={
disableControls ||
saving ||
modelDiscoveryFetching ||
discoveredModelsFiltered.length === 0 ||
allVisibleDiscoveredSelected
}
>
{t('ai_providers.model_discovery_select_visible')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClearDiscoveredModelSelection}
disabled={
disableControls ||
saving ||
modelDiscoveryFetching ||
modelDiscoverySelected.size === 0
}
>
{t('ai_providers.model_discovery_clear_selection')}
</Button>
</div>
<div className={styles.modelDiscoverySelectionSummary}>
{t('ai_providers.model_discovery_selected_count', {
count: modelDiscoverySelected.size,
})}
</div>
</div>
)}
{modelDiscoveryError && <div className="error-box">{modelDiscoveryError}</div>}
{modelDiscoveryFetching ? (
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_loading')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.gemini_models_fetch_loading')}
</div>
) : discoveredModels.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_empty')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.gemini_models_fetch_empty')}
</div>
) : discoveredModelsFiltered.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_search_empty')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.gemini_models_search_empty')}
</div>
) : (
<div className={styles.modelDiscoveryList}>
{discoveredModelsFiltered.map((model) => {
const checked = modelDiscoverySelected.has(model.name);
return (
<label
<SelectionCheckbox
key={model.name}
checked={checked}
onChange={() => toggleModelDiscoverySelection(model.name)}
disabled={disableControls || saving || modelDiscoveryFetching}
ariaLabel={model.name}
className={`${styles.modelDiscoveryRow} ${
checked ? styles.modelDiscoveryRowSelected : ''
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleModelDiscoverySelection(model.name)}
/>
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
labelClassName={styles.modelDiscoverySelectionLabel}
label={
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
</label>
}
/>
);
})}
</div>
+2 -2
View File
@@ -12,7 +12,7 @@ import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useNotificationStore } from '@/stores';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import type { ApiKeyEntry } from '@/types';
import { buildHeaderObject } from '@/utils/headers';
import { buildHeaderObject, hasHeader } from '@/utils/headers';
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils';
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
@@ -213,7 +213,7 @@ export function AiProvidersOpenAIEditPage() {
'Content-Type': 'application/json',
...customHeaders,
};
if (!headers.Authorization && !headers['authorization']) {
if (!hasHeader(headers, 'authorization')) {
headers.Authorization = `Bearer ${keyEntry.apiKey.trim()}`;
}
+92 -21
View File
@@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { modelsApi } from '@/services/api';
import type { ModelInfo } from '@/utils/models';
import { buildHeaderObject } from '@/utils/headers';
import { buildHeaderObject, hasHeader } from '@/utils/headers';
import { buildOpenAIModelsEndpoint } from '@/components/providers/utils';
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
import styles from './AiProvidersPage.module.scss';
@@ -48,6 +49,14 @@ export function AiProvidersOpenAIModelsPage() {
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
});
}, [models, search]);
const visibleModelNames = useMemo(
() => filteredModels.map((model) => model.name),
[filteredModels]
);
const allVisibleSelected = useMemo(
() => visibleModelNames.length > 0 && visibleModelNames.every((name) => selected.has(name)),
[selected, visibleModelNames]
);
const fetchOpenaiModelDiscovery = useCallback(
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
@@ -59,7 +68,7 @@ export function AiProvidersOpenAIModelsPage() {
try {
const headerObject = buildHeaderObject(form.headers);
const firstKey = form.apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
const hasAuthHeader = hasHeader(headerObject, 'authorization');
const list = await modelsApi.fetchModelsViaApiCall(
trimmedBaseUrl,
hasAuthHeader ? undefined : firstKey,
@@ -98,6 +107,22 @@ export function AiProvidersOpenAIModelsPage() {
void fetchOpenaiModelDiscovery();
}, [fetchOpenaiModelDiscovery, form.baseUrl, initialLoading]);
useEffect(() => {
const availableNames = new Set(models.map((model) => model.name));
setSelected((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (availableNames.has(name)) {
next.add(name);
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [models]);
const handleBack = useCallback(() => {
navigate(-1);
}, [navigate]);
@@ -126,6 +151,18 @@ export function AiProvidersOpenAIModelsPage() {
});
};
const handleSelectVisible = useCallback(() => {
setSelected((prev) => {
const next = new Set(prev);
visibleModelNames.forEach((name) => next.add(name));
return next;
});
}, [visibleModelNames]);
const handleClearSelection = useCallback(() => {
setSelected(new Set());
}, []);
const handleApply = () => {
const selectedModels = models.filter((model) => selected.has(model.name));
if (selectedModels.length) {
@@ -134,7 +171,7 @@ export function AiProvidersOpenAIModelsPage() {
handleBack();
};
const canApply = !disableControls && !saving && !fetching;
const canApply = !disableControls && !saving && !fetching && selected.size > 0;
return (
<SecondaryScreenShell
@@ -200,9 +237,42 @@ export function AiProvidersOpenAIModelsPage() {
onChange={(e) => setSearch(e.target.value)}
disabled={fetching}
/>
{models.length > 0 && (
<div className={styles.modelDiscoveryToolbar}>
<div className={styles.modelDiscoveryToolbarActions}>
<Button
variant="secondary"
size="sm"
onClick={handleSelectVisible}
disabled={
disableControls ||
saving ||
fetching ||
filteredModels.length === 0 ||
allVisibleSelected
}
>
{t('ai_providers.model_discovery_select_visible')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClearSelection}
disabled={disableControls || saving || fetching || selected.size === 0}
>
{t('ai_providers.model_discovery_clear_selection')}
</Button>
</div>
<div className={styles.modelDiscoverySelectionSummary}>
{t('ai_providers.model_discovery_selected_count', { count: selected.size })}
</div>
</div>
)}
{error && <div className="error-box">{error}</div>}
{fetching ? (
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_loading')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.openai_models_fetch_loading')}
</div>
) : models.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_empty')}</div>
) : filteredModels.length === 0 ? (
@@ -212,29 +282,30 @@ export function AiProvidersOpenAIModelsPage() {
{filteredModels.map((model) => {
const checked = selected.has(model.name);
return (
<label
<SelectionCheckbox
key={model.name}
checked={checked}
onChange={() => toggleSelection(model.name)}
disabled={disableControls || saving || fetching}
ariaLabel={model.name}
className={`${styles.modelDiscoveryRow} ${
checked ? styles.modelDiscoveryRowSelected : ''
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleSelection(model.name)}
/>
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
labelClassName={styles.modelDiscoverySelectionLabel}
label={
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
</label>
}
/>
);
})}
</div>
+34 -3
View File
@@ -327,15 +327,18 @@
}
.modelDiscoveryRow {
display: flex;
display: inline-flex;
align-items: flex-start;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
transition:
background 0.15s ease,
border-color 0.15s ease;
input[type='checkbox'] {
margin-top: 2px;
@@ -348,6 +351,11 @@
}
}
.modelDiscoverySelectionLabel {
flex: 1;
min-width: 0;
}
.modelDiscoveryRowSelected {
border-color: var(--primary-color);
background: var(--bg-tertiary);
@@ -376,6 +384,27 @@
line-height: 1.4;
}
.modelDiscoveryToolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
flex-wrap: wrap;
}
.modelDiscoveryToolbarActions {
display: flex;
align-items: center;
gap: $spacing-xs;
flex-wrap: wrap;
}
.modelDiscoverySelectionSummary {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.openaiTestButtonSuccess {
background-color: var(--success-badge-bg, #d1fae5);
border-color: var(--success-badge-border, #6ee7b7);
@@ -417,7 +446,9 @@
width: 100%;
height: 8px;
border-radius: 2px;
transition: transform 0.15s ease, opacity 0.15s ease;
transition:
transform 0.15s ease,
opacity 0.15s ease;
.statusBlockWrapper:hover &,
.statusBlockWrapper.statusBlockActive & {
+29 -1
View File
@@ -53,6 +53,7 @@
@include mobile {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
gap: 16px;
}
}
@@ -191,6 +192,11 @@
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
background: color-mix(in srgb, var(--bg-primary) 74%, transparent);
box-shadow: var(--shadow);
@include mobile {
padding: 16px;
border-radius: 28px;
}
}
.content {
@@ -434,6 +440,7 @@
.floatingStatus {
display: inline-flex;
align-items: center;
min-width: 0;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
@@ -449,6 +456,12 @@
text-overflow: ellipsis;
}
.floatingStatusCompact {
max-width: 112px;
padding: 0 8px;
font-size: 10px;
}
.floatingActionButton {
@include button-reset;
position: relative;
@@ -503,7 +516,9 @@
}
.floatingStatus {
display: none;
max-width: min(180px, 40vw);
font-size: 10px;
padding: 0 8px;
}
.floatingActionButton {
@@ -512,3 +527,16 @@
flex: 0 0 auto;
}
}
@media (max-width: 480px) {
.floatingStatus {
max-width: min(132px, 38vw);
}
}
@media (max-width: 380px) {
.pageHeader,
.workspaceShell {
padding: 14px;
}
}
+52 -5
View File
@@ -6,6 +6,7 @@ import { yaml } from '@codemirror/lang-yaml';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { keymap } from '@codemirror/view';
import { parse as parseYaml, parseDocument } from 'yaml';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import {
@@ -17,8 +18,9 @@ import {
} from '@/components/ui/icons';
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
import { DiffModal } from '@/components/config/DiffModal';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useVisualConfig } from '@/hooks/useVisualConfig';
import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores';
import { useNotificationStore, useAuthStore, useThemeStore, useConfigStore } from '@/stores';
import { configFileApi } from '@/services/api/configFile';
import styles from './ConfigPage.module.scss';
@@ -36,10 +38,13 @@ function readCommercialModeFromYaml(yamlContent: string): boolean {
export function ConfigPage() {
const { t } = useTranslation();
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const showNotification = useNotificationStore((state) => state.showNotification);
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isMobile = useMediaQuery('(max-width: 768px)');
const {
visualValues,
@@ -79,6 +84,7 @@ export function ConfigPage() {
const disableControls = connectionStatus !== 'connected';
const isDirty = dirty || visualDirty;
const shouldRenderFloatingActions = isCurrentLayer;
const hasVisualModeError = !!visualParseError;
const hasVisualValidationErrors =
activeTab === 'visual' &&
@@ -133,6 +139,24 @@ export function ConfigPage() {
setServerYaml(latestContent);
setMergedYaml(latestContent);
loadVisualValuesFromYaml(latestContent);
// Keep the global config store in sync so sidebar / other pages reflect YAML changes immediately.
try {
useConfigStore.getState().clearCache();
await useConfigStore.getState().fetchConfig(undefined, true);
} catch (refreshError: unknown) {
const message =
refreshError instanceof Error
? refreshError.message
: typeof refreshError === 'string'
? refreshError
: '';
showNotification(
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
showNotification(t('config_management.save_success'), 'success');
if (commercialModeChanged) {
showNotification(t('notification.commercial_mode_restart_required'), 'warning');
@@ -358,7 +382,7 @@ export function ConfigPage() {
// Keep bottom floating actions from covering page content by syncing its height to a CSS variable.
useLayoutEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window === 'undefined' || !shouldRenderFloatingActions) return;
const actionsEl = floatingActionsRef.current;
if (!actionsEl) return;
@@ -379,7 +403,7 @@ export function ConfigPage() {
window.removeEventListener('resize', updatePadding);
document.documentElement.style.removeProperty('--config-action-bar-height');
};
}, []);
}, [shouldRenderFloatingActions]);
// CodeMirror extensions
const extensions = useMemo(
@@ -407,6 +431,21 @@ export function ConfigPage() {
return '';
};
const getFloatingStatusText = () => {
if (!isMobile) return getStatusText();
if (disableControls)
return t('config_management.status_disconnected_short', { defaultValue: 'Disconnected' });
if (loading) return t('config_management.status_loading_short', { defaultValue: 'Loading' });
if (error) return t('config_management.status_load_failed_short', { defaultValue: 'Failed' });
if (hasVisualModeError)
return t('config_management.visual_mode_unavailable_short', { defaultValue: 'YAML issue' });
if (hasVisualValidationErrors)
return t('config_management.visual.validation_blocked_short', { defaultValue: 'Fix errors' });
if (saving) return t('config_management.status_saving_short', { defaultValue: 'Saving' });
if (isDirty) return t('config_management.status_dirty_short', { defaultValue: 'Unsaved' });
return t('config_management.status_loaded_short', { defaultValue: 'Loaded' });
};
const handleReload = useCallback(() => {
if (!isDirty) {
void loadConfig();
@@ -428,7 +467,13 @@ export function ConfigPage() {
const floatingActions = (
<div className={styles.floatingActionContainer} ref={floatingActionsRef}>
<div className={styles.floatingActionList}>
<div className={`${styles.floatingStatus} ${getStatusClass()}`}>{getStatusText()}</div>
<div
className={`${styles.floatingStatus} ${
isMobile ? styles.floatingStatusCompact : ''
} ${getStatusClass()}`}
>
{getFloatingStatusText()}
</div>
<button
type="button"
className={styles.floatingActionButton}
@@ -622,7 +667,9 @@ export function ConfigPage() {
</div>
</div>
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
{shouldRenderFloatingActions && typeof document !== 'undefined'
? createPortal(floatingActions, document.body)
: null}
<DiffModal
open={diffModalOpen}
original={serverYaml}
+3 -5
View File
@@ -90,7 +90,7 @@ export const modelsApi = {
}
const resolvedHeaders = { ...headers };
if (apiKey) {
if (apiKey && !hasHeader(resolvedHeaders, 'authorization')) {
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
}
@@ -116,8 +116,7 @@ export const modelsApi = {
}
const resolvedHeaders = { ...headers };
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
if (apiKey && !hasAuthHeader) {
if (apiKey && !hasHeader(resolvedHeaders, 'authorization')) {
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
}
@@ -149,8 +148,7 @@ export const modelsApi = {
}
const resolvedHeaders = { ...headers };
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
if (apiKey && !hasAuthHeader) {
if (apiKey && !hasHeader(resolvedHeaders, 'authorization')) {
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
}
+19 -10
View File
@@ -1,20 +1,27 @@
/**
* 安全存储服务
* 本地存储混淆服务(可逆)
* 基于原项目 src/utils/secure-storage.js
*
* IMPORTANT: 这不是安全边界,仅用于避免“肉眼直读”的轻度混淆。
*/
import { encryptData, decryptData } from '@/utils/encryption';
import { obfuscateData, deobfuscateData, isObfuscated } from '@/utils/encryption';
interface StorageOptions {
/**
* Whether to obfuscate the stored value. This was historically called `encrypt`,
* but the implementation is reversible obfuscation, not cryptographic security.
*/
obfuscate?: boolean;
encrypt?: boolean;
}
class SecureStorageService {
class ObfuscatedStorageService {
/**
* 存储数据
*/
setItem(key: string, value: unknown, options: StorageOptions = {}): void {
const { encrypt = true } = options;
const obfuscate = options.obfuscate ?? options.encrypt ?? true;
if (value === null || value === undefined) {
this.removeItem(key);
@@ -22,7 +29,7 @@ class SecureStorageService {
}
const stringValue = JSON.stringify(value);
const storedValue = encrypt ? encryptData(stringValue) : stringValue;
const storedValue = obfuscate ? obfuscateData(stringValue) : stringValue;
localStorage.setItem(key, storedValue);
}
@@ -31,20 +38,20 @@ class SecureStorageService {
* 获取数据
*/
getItem<T = unknown>(key: string, options: StorageOptions = {}): T | null {
const { encrypt = true } = options;
const obfuscate = options.obfuscate ?? options.encrypt ?? true;
const raw = localStorage.getItem(key);
if (raw === null) return null;
try {
const decrypted = encrypt ? decryptData(raw) : raw;
const decrypted = obfuscate ? deobfuscateData(raw) : raw;
return JSON.parse(decrypted) as T;
} catch {
// JSON解析失败,尝试兼容旧的纯字符串数据 (非JSON格式)
try {
// 如果是加密的,尝试解密后直接返回
if (encrypt && raw.startsWith('enc::v1::')) {
const decrypted = decryptData(raw);
if (obfuscate && isObfuscated(raw)) {
const decrypted = deobfuscateData(raw);
// 解密后如果还不是JSON,返回原始字符串
return decrypted as T;
}
@@ -108,4 +115,6 @@ class SecureStorageService {
}
}
export const secureStorage = new SecureStorageService();
export const obfuscatedStorage = new ObfuscatedStorageService();
// Backward-compatible alias (historically named "secureStorage").
export const secureStorage = obfuscatedStorage;
+11 -8
View File
@@ -7,10 +7,11 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { AuthState, LoginCredentials, ConnectionStatus } from '@/types';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
import { secureStorage } from '@/services/storage/secureStorage';
import { obfuscatedStorage } from '@/services/storage/secureStorage';
import { apiClient } from '@/services/api/client';
import { useConfigStore } from './useConfigStore';
import { useUsageStatsStore } from './useUsageStatsStore';
import { useModelsStore } from './useModelsStore';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
interface AuthStoreState extends AuthState {
@@ -46,13 +47,13 @@ export const useAuthStore = create<AuthStoreState>()(
if (restoreSessionPromise) return restoreSessionPromise;
restoreSessionPromise = (async () => {
secureStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']);
obfuscatedStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']);
const wasLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
const legacyBase =
secureStorage.getItem<string>('apiBase') ||
secureStorage.getItem<string>('apiUrl', { encrypt: true });
const legacyKey = secureStorage.getItem<string>('managementKey');
obfuscatedStorage.getItem<string>('apiBase') ||
obfuscatedStorage.getItem<string>('apiUrl', { encrypt: true });
const legacyKey = obfuscatedStorage.getItem<string>('managementKey');
const { apiBase, managementKey, rememberPassword } = get();
const resolvedBase = normalizeApiBase(apiBase || legacyBase || detectApiBaseFromLocation());
@@ -94,6 +95,7 @@ export const useAuthStore = create<AuthStoreState>()(
try {
set({ connectionStatus: 'connecting' });
useModelsStore.getState().clearCache();
// 配置 API 客户端
apiClient.setConfig({
@@ -138,6 +140,7 @@ export const useAuthStore = create<AuthStoreState>()(
restoreSessionPromise = null;
useConfigStore.getState().clearCache();
useUsageStatsStore.getState().clearUsageStats();
useModelsStore.getState().clearCache();
set({
isAuthenticated: false,
apiBase: '',
@@ -197,14 +200,14 @@ export const useAuthStore = create<AuthStoreState>()(
name: STORAGE_KEY_AUTH,
storage: createJSONStorage(() => ({
getItem: (name) => {
const data = secureStorage.getItem<AuthStoreState>(name);
const data = obfuscatedStorage.getItem<AuthStoreState>(name);
return data ? JSON.stringify(data) : null;
},
setItem: (name, value) => {
secureStorage.setItem(name, JSON.parse(value));
obfuscatedStorage.setItem(name, JSON.parse(value));
},
removeItem: (name) => {
secureStorage.removeItem(name);
obfuscatedStorage.removeItem(name);
}
})),
partialize: (state) => ({
+6 -1
View File
@@ -267,7 +267,12 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
// 同时清除完整配置缓存
newCache.delete('__full__');
set({ cache: newCache });
// Section-level invalidation usually follows an optimistic write path. Invalidate any in-flight
// full fetch so stale responses can't overwrite newer local changes.
configRequestToken += 1;
inFlightConfigRequest = null;
set({ cache: newCache, loading: false, error: null });
return;
} else {
newCache.clear();
+9 -5
View File
@@ -11,6 +11,7 @@ interface ModelsCache {
data: ModelInfo[];
timestamp: number;
apiBase: string;
apiKey: string;
}
interface ModelsState {
@@ -21,7 +22,7 @@ interface ModelsState {
fetchModels: (apiBase: string, apiKey?: string, forceRefresh?: boolean) => Promise<ModelInfo[]>;
clearCache: () => void;
isCacheValid: (apiBase: string) => boolean;
isCacheValid: (apiBase: string, apiKey?: string) => boolean;
}
export const useModelsStore = create<ModelsState>((set, get) => ({
@@ -32,9 +33,10 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
fetchModels: async (apiBase, apiKey, forceRefresh = false) => {
const { cache, isCacheValid } = get();
const apiKeyScope = apiKey?.trim() || '';
// 检查缓存
if (!forceRefresh && isCacheValid(apiBase) && cache) {
if (!forceRefresh && isCacheValid(apiBase, apiKeyScope) && cache) {
set({ models: cache.data, error: null });
return cache.data;
}
@@ -42,13 +44,13 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
set({ loading: true, error: null });
try {
const list = await modelsApi.fetchModels(apiBase, apiKey);
const list = await modelsApi.fetchModels(apiBase, apiKeyScope || undefined);
const now = Date.now();
set({
models: list,
loading: false,
cache: { data: list, timestamp: now, apiBase }
cache: { data: list, timestamp: now, apiBase, apiKey: apiKeyScope }
});
return list;
@@ -68,10 +70,12 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
set({ cache: null, models: [] });
},
isCacheValid: (apiBase) => {
isCacheValid: (apiBase, apiKey) => {
const { cache } = get();
if (!cache) return false;
if (cache.apiBase !== apiBase) return false;
const apiKeyScope = apiKey?.trim() || '';
if ((cache.apiKey || '') !== apiKeyScope) return false;
return Date.now() - cache.timestamp < CACHE_EXPIRY_MS;
}
}));
+44
View File
@@ -411,6 +411,7 @@ textarea {
border-radius: $radius-lg;
border: 1px solid var(--border-color);
box-shadow: $shadow-lg;
max-width: 100%;
max-height: 90vh;
overflow: hidden;
display: flex;
@@ -523,6 +524,49 @@ textarea {
background: var(--bg-primary);
}
@media (max-width: $breakpoint-mobile) {
.modal-overlay {
padding: $spacing-md;
}
.modal {
max-height: calc(100vh - #{$spacing-md * 2});
border-radius: $radius-md;
}
@supports (height: 100dvh) {
.modal {
max-height: calc(100dvh - #{$spacing-md * 2});
}
}
.modal-header {
padding: $spacing-md;
padding-right: 52px;
}
.modal-body {
padding: $spacing-md;
max-height: min(60vh, calc(100vh - 180px));
}
@supports (height: 100dvh) {
.modal-body {
max-height: min(60dvh, calc(100dvh - 180px));
}
}
.modal-footer {
padding: $spacing-md;
flex-direction: column-reverse;
align-items: stretch;
}
.modal-footer .btn {
width: 100%;
}
}
.request-log-modal {
display: flex;
flex-direction: column;
+14 -7
View File
@@ -1,6 +1,8 @@
/**
* 加密工具函数
* 本地存储混淆工具函数(可逆)
* 从原项目 src/utils/secure-storage.js 迁移
*
* IMPORTANT: 这不是安全边界。浏览器端长期持久化的密钥仍应视为可被读取。
*/
const ENC_PREFIX = 'enc::v1::';
@@ -26,7 +28,7 @@ function getKeyBytes(): Uint8Array {
const ua = navigator.userAgent;
cachedKeyBytes = encodeText(`${SECRET_SALT}|${host}|${ua}`);
} catch (error) {
console.warn('Encryption fallback to simple key:', error);
console.warn('Obfuscation fallback to simple key:', error);
cachedKeyBytes = encodeText(SECRET_SALT);
}
@@ -61,7 +63,7 @@ function fromBase64(base64: string): Uint8Array {
/**
* 加密数据
*/
export function encryptData(value: string): string {
export function obfuscateData(value: string): string {
if (!value) return value;
try {
@@ -69,7 +71,7 @@ export function encryptData(value: string): string {
const encrypted = xorBytes(encodeText(value), keyBytes);
return `${ENC_PREFIX}${toBase64(encrypted)}`;
} catch (error) {
console.warn('Encryption failed, fallback to plaintext:', error);
console.warn('Obfuscation failed, fallback to plaintext:', error);
return value;
}
}
@@ -77,7 +79,7 @@ export function encryptData(value: string): string {
/**
* 解密数据
*/
export function decryptData(payload: string): string {
export function deobfuscateData(payload: string): string {
if (!payload || !payload.startsWith(ENC_PREFIX)) {
return payload;
}
@@ -88,7 +90,7 @@ export function decryptData(payload: string): string {
const decrypted = xorBytes(encrypted, getKeyBytes());
return decodeText(decrypted);
} catch (error) {
console.warn('Decryption failed, return as-is:', error);
console.warn('Deobfuscation failed, return as-is:', error);
return payload;
}
}
@@ -96,6 +98,11 @@ export function decryptData(payload: string): string {
/**
* 检查是否已加密
*/
export function isEncrypted(value: string): boolean {
export function isObfuscated(value: string): boolean {
return value?.startsWith(ENC_PREFIX) || false;
}
// Backward-compatible aliases (this module was historically named "encryption").
export const encryptData = obfuscateData;
export const decryptData = deobfuscateData;
export const isEncrypted = isObfuscated;
+6
View File
@@ -31,6 +31,12 @@ export function buildHeaderObject(input?: HeaderEntry[] | Record<string, string
}, {});
}
export function hasHeader(headers: Record<string, unknown> | null | undefined, name: string): boolean {
if (!headers) return false;
const target = name.toLowerCase();
return Object.keys(headers).some((key) => key.toLowerCase() === target);
}
export function headersToEntries(headers?: Record<string, string | undefined | null>): HeaderEntry[] {
if (!headers || typeof headers !== 'object') return [];
return Object.entries(headers)